From 2fd85f66ab889cedc0fe9964fcc07ea3c73ba4f4 Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Mon, 10 Aug 2026 14:54:58 +0530 Subject: [PATCH 1/3] test: add RED regression test proving repo-planted flow_scanner cannot shadow bundled scanner via cwd --- .../test/python/PythonCommandExecutor.test.ts | 50 +++++++++++++++++++ .../test-data/executable-scripts/cwd-probe.py | 10 ++++ 2 files changed, 60 insertions(+) create mode 100644 packages/code-analyzer-flow-engine/test/test-data/executable-scripts/cwd-probe.py diff --git a/packages/code-analyzer-flow-engine/test/python/PythonCommandExecutor.test.ts b/packages/code-analyzer-flow-engine/test/python/PythonCommandExecutor.test.ts index 381d47e4..c8c63cb6 100644 --- a/packages/code-analyzer-flow-engine/test/python/PythonCommandExecutor.test.ts +++ b/packages/code-analyzer-flow-engine/test/python/PythonCommandExecutor.test.ts @@ -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', () => { @@ -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}); + } + }); }); }); \ No newline at end of file diff --git a/packages/code-analyzer-flow-engine/test/test-data/executable-scripts/cwd-probe.py b/packages/code-analyzer-flow-engine/test/test-data/executable-scripts/cwd-probe.py new file mode 100644 index 00000000..b91dbfc7 --- /dev/null +++ b/packages/code-analyzer-flow-engine/test/test-data/executable-scripts/cwd-probe.py @@ -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]})) From a69a132f4d671c17681f6a76472563b75827237d Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Mon, 10 Aug 2026 14:55:38 +0530 Subject: [PATCH 2/3] fix: pin spawned python cwd to trusted FlowScanner root to prevent module shadowing RCE (CWE-427) --- packages/code-analyzer-flow-engine/package.json | 2 +- .../src/python/PythonCommandExecutor.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/code-analyzer-flow-engine/package.json b/packages/code-analyzer-flow-engine/package.json index ec8b3659..2a9adc39 100644 --- a/packages/code-analyzer-flow-engine/package.json +++ b/packages/code-analyzer-flow-engine/package.json @@ -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.0", + "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", diff --git a/packages/code-analyzer-flow-engine/src/python/PythonCommandExecutor.ts b/packages/code-analyzer-flow-engine/src/python/PythonCommandExecutor.ts index 71433756..f49d6cae 100644 --- a/packages/code-analyzer-flow-engine/src/python/PythonCommandExecutor.ts +++ b/packages/code-analyzer-flow-engine/src/python/PythonCommandExecutor.ts @@ -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 From e3de90b3e7b908301dd2f71be12ae662e409d3ad Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Mon, 10 Aug 2026 14:56:25 +0530 Subject: [PATCH 3/3] test: add wrapper-level end-to-end test that bundled flow_scanner resolves and cannot be shadowed --- .../python/FlowScannerCommandWrapper.test.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/packages/code-analyzer-flow-engine/test/python/FlowScannerCommandWrapper.test.ts b/packages/code-analyzer-flow-engine/test/python/FlowScannerCommandWrapper.test.ts index ed2bc2e9..48bda38f 100644 --- a/packages/code-analyzer-flow-engine/test/python/FlowScannerCommandWrapper.test.ts +++ b/packages/code-analyzer-flow-engine/test/python/FlowScannerCommandWrapper.test.ts @@ -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();