Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/code-analyzer-flow-engine/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@salesforce/code-analyzer-flow-engine",
"description": "Plugin package that adds 'Flow Scanner' as an engine into Salesforce Code Analyzer",
"version": "0.40.1-SNAPSHOT",
"version": "0.41.0-SNAPSHOT",
"author": "The Salesforce Code Analyzer Team",
"license": "BSD-3-Clause",
"homepage": "https://developer.salesforce.com/docs/platform/salesforce-code-analyzer/overview",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ export class PythonCommandExecutor {
const stderrMessages: string[] = [];

const pythonProcess: ChildProcessWithoutNullStreams = spawn(this.pythonCommand, pythonCmdArgs, {
// Pin the child's working directory to the trusted bundled FlowScanner root. When python is
// invoked with '-m flow_scanner', CPython places the process's cwd at sys.path[0], ahead of
// PYTHONPATH. Inheriting the CLI's cwd (the scanned repo) would let a repo-planted
// `flow_scanner` package shadow the bundled scanner and execute attacker code (CWE-427). All
// file arguments the wrapper passes are absolute, so pinning cwd here is behavior-preserving.
cwd: PATH_TO_FLOW_SCANNER_ROOT,
env: {
...process.env,
PYTHONPATH: PATH_TO_FLOW_SCANNER_ROOT
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,77 @@ describe('FlowScannerCommandWrapper implementations', () => {
});
});

describe('Module shadowing resistance', () => {
const wrapper: RunTimeFlowScannerCommandWrapper = new RunTimeFlowScannerCommandWrapper(PYTHON_COMMAND);

it('resolves the bundled flow_scanner even when a malicious flow_scanner is planted in the process cwd', async () => {
// End-to-end defense-in-depth check for the CWE-427 module-shadowing RCE. We plant a hostile
// `flow_scanner` package into a directory, make it the process cwd (as would happen when the
// CLI is run from within a scanned repo), and confirm the wrapper still executes the trusted
// bundled scanner (results match the goldfile) and that the hostile payload never runs.
const scannedRepoDir: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'shadow-repo-'));
const isolatedWorkingFolder: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'shadow-work-'));
const isolatedLogFile: string = path.join(isolatedWorkingFolder, 'flow_scanner_logfile.log');
const sentinelFile: string = path.join(scannedRepoDir, 'PWNED.txt');
const originalCwd: string = process.cwd();
try {
// Plant a hostile `flow_scanner` package that writes a sentinel and fake (empty) results
// if it is ever imported/executed in place of the bundled scanner.
const maliciousModuleDir: string = path.join(scannedRepoDir, 'flow_scanner');
await fs.promises.mkdir(maliciousModuleDir);
await fs.promises.writeFile(path.join(maliciousModuleDir, '__init__.py'), '', 'utf-8');
await fs.promises.writeFile(path.join(maliciousModuleDir, '__main__.py'),
`import os, sys\n` +
`with open(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'PWNED.txt'), 'w') as f:\n` +
` f.write('pwned')\n` +
`# Write empty results to the --json path so the run would appear clean.\n` +
`if '--json' in sys.argv:\n` +
` with open(sys.argv[sys.argv.index('--json') + 1], 'w') as f:\n` +
` f.write('{"results": {}}')\n`,
'utf-8');

// Simulate the CLI being invoked from within the malicious scanned repo.
process.chdir(scannedRepoDir);

const shadowResults: FlowScannerExecutionResult = await wrapper.runFlowScannerRules(
isolatedWorkingFolder,
[PATH_TO_EXAMPLE1, PATH_TO_EXAMPLE2],
[PATH_TO_EXAMPLE1, PATH_TO_EXAMPLE2],
isolatedLogFile,
['PreventPassingUserDataIntoElementWithoutSharing', 'PreventPassingUserDataIntoElementWithSharing', 'MissingFaultHandler'],
() => {});
for (const queryName of Object.keys(shadowResults.results)) {
for (const queryResults of shadowResults.results[queryName]) {
delete queryResults.counter;
}
}

// The planted hostile module must never have executed.
expect(fs.existsSync(sentinelFile)).toEqual(false);

// The bundled scanner produced real results (proving it ran, not the empty-results payload).
const goldFileContents: string = (await fs.promises.readFile(path.join(PATH_TO_GOLDFILES, 'results.goldfile.json'), {encoding: 'utf-8'}))
.replaceAll('"__PATH_TO_EXAMPLE1__"', JSON.stringify(PATH_TO_EXAMPLE1))
.replaceAll('"__PATH_TO_EXAMPLE2__"', JSON.stringify(PATH_TO_EXAMPLE2));
const expectedResults: FlowScannerExecutionResult = JSON.parse(goldFileContents) as FlowScannerExecutionResult;

const expectedKeys: string[] = Object.keys(expectedResults.results);
expect(Object.keys(shadowResults.results)).toHaveLength(expectedKeys.length);
for (const key of expectedKeys) {
expect(key in shadowResults.results).toEqual(true);
expect(shadowResults.results[key]).toHaveLength(expectedResults.results[key].length);
for (const expectedElement of expectedResults.results[key]) {
expect(shadowResults.results[key]).toContainEqual(expectedElement);
}
}
} finally {
process.chdir(originalCwd);
await fs.promises.rm(scannedRepoDir, {recursive: true, force: true});
await fs.promises.rm(isolatedWorkingFolder, {recursive: true, force: true});
}
});
});

describe('Failure Modes', () => {
afterEach(() => {
jest.restoreAllMocks();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import * as fsp from 'node:fs/promises';

import {PythonCommandExecutor} from '../../src/python/PythonCommandExecutor';

const PATH_TO_ERROR_THROWER = path.resolve(__dirname, '..', 'test-data', 'executable-scripts', 'error-thrower.py');
const PATH_TO_CWD_PROBE = path.resolve(__dirname, '..', 'test-data', 'executable-scripts', 'cwd-probe.py');
// The trust anchor: the bundled FlowScanner root that the executor pins PYTHONPATH to. Mirrors
// PATH_TO_FLOW_SCANNER_ROOT in PythonCommandExecutor.ts (path.join(__dirname, '..', '..', 'FlowScanner')),
// resolved from the compiled dist/python directory. From the test's location that is two levels up.
const PATH_TO_FLOW_SCANNER_ROOT = fs.realpathSync(path.resolve(__dirname, '..', '..', 'FlowScanner'));


describe('PythonCommandExecutor', () => {
Expand All @@ -28,5 +35,48 @@ describe('PythonCommandExecutor', () => {
// typically use.
expect(msg.replaceAll('\r\n', '\n')).toEqual(expectedOutput);
});

it('When scanned repo plants a flow_scanner module, spawned python does not execute it (no cwd-based sys.path[0] shadowing)', async () => {
// Regression test for the CWE-427 module-shadowing RCE: because CPython places the process's
// current working directory at sys.path[0] (ahead of PYTHONPATH), a spawned python that inherits
// the CLI's cwd (the scanned repo) would resolve a repo-planted `flow_scanner` package instead of
// the trusted bundled one. We prove the child process runs from the trusted FlowScanner root and
// that a hostile payload planted in the scanned directory is never executed.
const tempDir: string = await fsp.mkdtemp(path.join(os.tmpdir(), 'flow-shadowing-test-'));
const sentinelFile: string = path.join(tempDir, 'PWNED.txt');
const originalCwd: string = process.cwd();
try {
// Plant a hostile `flow_scanner` package that writes a sentinel file if it is ever imported/run.
const maliciousModuleDir: string = path.join(tempDir, 'flow_scanner');
await fsp.mkdir(maliciousModuleDir);
await fsp.writeFile(path.join(maliciousModuleDir, '__init__.py'), '', 'utf-8');
await fsp.writeFile(path.join(maliciousModuleDir, '__main__.py'),
`import os\n` +
`with open(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'PWNED.txt'), 'w') as f:\n` +
` f.write('pwned')\n`,
'utf-8');

// Simulate the CLI being invoked from within the scanned repo.
process.chdir(tempDir);

let capturedStdout: string = '';
await executor.exec([PATH_TO_CWD_PROBE], (line: string) => {
capturedStdout += line;
});

const probeResult: {cwd: string; syspath0: string} = JSON.parse(capturedStdout);
// The spawned child's working directory is what CPython places at sys.path[0] when invoked
// with `-m flow_scanner`. Pinning it to the trusted bundled FlowScanner root (rather than the
// inherited, attacker-controlled scanned repo) is exactly what closes the shadowing vector.
expect(fs.realpathSync(probeResult.cwd)).toEqual(PATH_TO_FLOW_SCANNER_ROOT);
expect(fs.realpathSync(probeResult.cwd)).not.toEqual(fs.realpathSync(tempDir));

// Defense-in-depth: no hostile payload planted in the scanned directory was executed.
expect(fs.existsSync(sentinelFile)).toEqual(false);
} finally {
process.chdir(originalCwd);
await fsp.rm(tempDir, {recursive: true, force: true});
}
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import json
import os
import sys


# Emit the working directory and the first entry of the module search path so
# that tests can assert where a spawned python process would resolve modules
# from. This is used to prove that a spawned scanner process cannot be tricked
# into resolving modules from an untrusted, scanned workspace directory.
print(json.dumps({'cwd': os.getcwd(), 'syspath0': sys.path[0]}))
Loading