From b371c21f7fa5ae544c5628bcfef30d976ce923f7 Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Sun, 23 Aug 2026 11:03:32 +0530 Subject: [PATCH 1/6] fix(sfge): pin trusted cwd on java -version spawn to block repo-local java.exe shadowing (CWE-427) SFGE's RuntimeJavaVersionIdentifier spawned 'java -version' with no cwd option. On Windows, the bare-'java' auto-detect fallback let child_process resolve an attacker-committed java.exe from the inherited scanned-repo cwd ahead of the trusted PATH entry, executing attacker code before rule listing began (CWE-427 search-path element). Pin the spawn's cwd to the engine's installed module directory (__dirname), which is installer-controlled and never the scanned repo. Absolute-path and PATH-resolved java commands are unaffected, preserving behavior for legitimate java_command configs. Mirrors the Flow engine's PythonCommandExecutor cwd pin (commit a69a132). Adds regression tests (fake-java probe reports its spawn cwd; planted repo-local java sentinel) proving the child runs from the trusted dir and a repo-local java is never invoked. --- .../src/java-version-identifier.ts | 10 ++- .../test/java-version-identifier.test.ts | 75 ++++++++++++++++++- .../test-data/executable-scripts/fake-java.sh | 14 ++++ .../executable-scripts/planted-java-marker.sh | 8 ++ 4 files changed, 105 insertions(+), 2 deletions(-) create mode 100755 packages/code-analyzer-sfge-engine/test/test-data/executable-scripts/fake-java.sh create mode 100755 packages/code-analyzer-sfge-engine/test/test-data/executable-scripts/planted-java-marker.sh diff --git a/packages/code-analyzer-sfge-engine/src/java-version-identifier.ts b/packages/code-analyzer-sfge-engine/src/java-version-identifier.ts index 86afb981..c3f405b3 100644 --- a/packages/code-analyzer-sfge-engine/src/java-version-identifier.ts +++ b/packages/code-analyzer-sfge-engine/src/java-version-identifier.ts @@ -20,7 +20,15 @@ export class RuntimeJavaVersionIdentifier implements JavaVersionIdentifier { // If instead we used java --version then the output would look something like: // * (from Win10): "openjdk 14 2020-03-17\r\nOpenJDK Runtime Environment (build 14+36-1461)\r\nOpenJDK 64-Bit Server VM (build 14+36-1461, mixed mode, sharing)\r\n" // Notice it doesn't have the word "version" which is why we don't call "--version" but instead call "-version". - const childProcess: cp.ChildProcessWithoutNullStreams = cp.spawn(javaCommand, ['-version']); + // + // We pin the child's working directory to this engine's installed module directory (__dirname), + // never the inherited, attacker-controllable scanned-repo cwd. On Windows, spawning a bare command + // name like "java" (the auto-detect fallback in config.ts) without pinning cwd lets the OS resolve + // a repo-local java/java.exe from the current directory ahead of the trusted PATH entry, executing + // attacker code (CWE-427 search-path element). Absolute-path and PATH-resolved java commands are + // unaffected, so behavior is preserved for legitimate configs. Mirrors the Flow engine's + // PythonCommandExecutor cwd pin. + const childProcess: cp.ChildProcessWithoutNullStreams = cp.spawn(javaCommand, ['-version'], {cwd: __dirname}); let stderr: string = ''; childProcess.stderr.on('data', (data: Buffer) => { diff --git a/packages/code-analyzer-sfge-engine/test/java-version-identifier.test.ts b/packages/code-analyzer-sfge-engine/test/java-version-identifier.test.ts index 9b074f75..fe8efc79 100644 --- a/packages/code-analyzer-sfge-engine/test/java-version-identifier.test.ts +++ b/packages/code-analyzer-sfge-engine/test/java-version-identifier.test.ts @@ -1,5 +1,21 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import * as fsp from "node:fs/promises"; import {SemVer} from "semver"; -import {_extractJavaVersionFrom} from "../src/java-version-identifier"; +import {_extractJavaVersionFrom, RuntimeJavaVersionIdentifier} from "../src/java-version-identifier"; + +const EXECUTABLE_SCRIPTS_DIR: string = path.resolve(__dirname, 'test-data', 'executable-scripts'); +const PATH_TO_FAKE_JAVA: string = path.join(EXECUTABLE_SCRIPTS_DIR, 'fake-java.sh'); +const PATH_TO_PLANTED_JAVA_MARKER: string = path.join(EXECUTABLE_SCRIPTS_DIR, 'planted-java-marker.sh'); +// The fake-java probe records the cwd it was actually spawned with into this file (a fixed path alongside +// the script). We report through a file rather than reading the child's stdout because jest's node +// testEnvironment sandboxes process.env, so an env-var-based channel would not reach the spawned child. +const PATH_TO_SPAWN_CWD_REPORT: string = path.join(EXECUTABLE_SCRIPTS_DIR, 'spawn-cwd-report.txt'); +// The trust anchor: the engine's own installed module directory that identifyJavaVersion pins the java +// -version spawn's cwd to. This mirrors __dirname of the source module (java-version-identifier.ts). Under +// ts-jest the source runs from src (not dist), so the trusted directory resolves to the package's src dir. +const TRUSTED_DIR: string = fs.realpathSync(path.resolve(__dirname, '..', 'src')); describe('Test for _extractJavaVersionFrom helper', () => { type VERSION_CASE = {description: string, input: string, expected: SemVer}; @@ -39,3 +55,60 @@ describe('Test for _extractJavaVersionFrom helper', () => { expect(_extractJavaVersionFrom('this is garbage')).toEqual(null); }); }); + +describe('Security regression: java -version spawn is not satisfiable by a repo-local (cwd) executable', () => { + // Regression tests for the CWE-427 search-path-element RCE. On Windows, spawning a bare command name + // (the "java" auto-detect fallback) without pinning cwd lets child_process resolve an attacker-committed + // java.exe from the inherited scanned-repo cwd ahead of the trusted PATH entry. Pinning the spawn's cwd + // to this engine's installed module directory (__dirname) closes that vector while leaving absolute-path + // and PATH-resolved java resolution unchanged. Mirrors the Flow engine test pattern + // (test/python/PythonCommandExecutor.test.ts). + + it('spawned java child runs from a trusted directory, not the inherited scanned-repo cwd', async () => { + const tempDir: string = await fsp.mkdtemp(path.join(os.tmpdir(), 'sfge-java-cwd-test-')); + const originalCwd: string = process.cwd(); + try { + await fsp.rm(PATH_TO_SPAWN_CWD_REPORT, {force: true}); + // Simulate the CLI being invoked from within the scanned (attacker-controlled) repo. + process.chdir(tempDir); + + await new RuntimeJavaVersionIdentifier().identifyJavaVersion(PATH_TO_FAKE_JAVA); + + const reportedCwd: string = (await fsp.readFile(PATH_TO_SPAWN_CWD_REPORT, {encoding: 'utf-8'})).trim(); + // The spawned child's cwd must be the trusted module directory, never the inherited scanned repo. + expect(fs.realpathSync(reportedCwd)).toEqual(TRUSTED_DIR); + expect(fs.realpathSync(reportedCwd)).not.toEqual(fs.realpathSync(tempDir)); + } finally { + process.chdir(originalCwd); + await fsp.rm(tempDir, {recursive: true, force: true}); + await fsp.rm(PATH_TO_SPAWN_CWD_REPORT, {force: true}); + } + }); + + it('a java executable planted in the scanned-repo cwd is never invoked', async () => { + const tempDir: string = await fsp.mkdtemp(path.join(os.tmpdir(), 'sfge-java-planted-test-')); + const sentinelFile: string = path.join(tempDir, 'PWNED.txt'); + const originalCwd: string = process.cwd(); + try { + const markerContents: string = await fsp.readFile(PATH_TO_PLANTED_JAVA_MARKER, {encoding: 'utf-8'}); + // Plant a hostile executable named exactly as the bare-command fallback would resolve on each OS. + const plantedNames: string[] = process.platform === 'win32' ? ['java', 'java.exe'] : ['java']; + for (const name of plantedNames) { + const plantedPath: string = path.join(tempDir, name); + await fsp.writeFile(plantedPath, markerContents, {encoding: 'utf-8', mode: 0o755}); + await fsp.chmod(plantedPath, 0o755); + } + process.chdir(tempDir); + + // Probe with the known-good fake java so the promise resolves regardless of the pin working; the + // assertion of interest is purely whether the planted repo-local executable was ever executed. + await new RuntimeJavaVersionIdentifier().identifyJavaVersion(PATH_TO_FAKE_JAVA); + + expect(fs.existsSync(sentinelFile)).toEqual(false); + } finally { + process.chdir(originalCwd); + await fsp.rm(tempDir, {recursive: true, force: true}); + await fsp.rm(PATH_TO_SPAWN_CWD_REPORT, {force: true}); + } + }); +}); diff --git a/packages/code-analyzer-sfge-engine/test/test-data/executable-scripts/fake-java.sh b/packages/code-analyzer-sfge-engine/test/test-data/executable-scripts/fake-java.sh new file mode 100755 index 00000000..771a5101 --- /dev/null +++ b/packages/code-analyzer-sfge-engine/test/test-data/executable-scripts/fake-java.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Fake "java" probe used by the CWE-427 regression tests. It records the working +# directory the child process was actually spawned with so the test can assert the +# spawn cwd is the trusted module directory rather than the inherited scanned-repo +# cwd. The cwd is written both to the file named by the CWD_REPORT_FILE env var (when +# it propagates) and to a report file alongside this script at a path the test can +# always resolve. It then prints a parseable `java -version` style line to STDERR and +# exits 0 so the version-probe logic under test resolves normally. +if [ -n "$CWD_REPORT_FILE" ]; then + pwd > "$CWD_REPORT_FILE" +fi +pwd > "$(dirname "$0")/spawn-cwd-report.txt" +echo 'openjdk version "11.0.6" 2020-01-14' 1>&2 +exit 0 diff --git a/packages/code-analyzer-sfge-engine/test/test-data/executable-scripts/planted-java-marker.sh b/packages/code-analyzer-sfge-engine/test/test-data/executable-scripts/planted-java-marker.sh new file mode 100755 index 00000000..57653401 --- /dev/null +++ b/packages/code-analyzer-sfge-engine/test/test-data/executable-scripts/planted-java-marker.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# Hostile stand-in for an attacker-committed repo-local `java`/`java.exe`. If this +# ever executes it means the java lookup was hijacked by the scanned-repo cwd, so +# it drops a PWNED sentinel next to itself as proof-of-execution. The regression +# test asserts this sentinel never appears. +echo 'PWNED' > "$(dirname "$0")/PWNED.txt" +echo 'openjdk version "11.0.6" 2020-01-14' 1>&2 +exit 0 From fbaa01167b43481780cfa0379755c893d6fd1b4c Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Sun, 23 Aug 2026 11:03:54 +0530 Subject: [PATCH 2/6] fix(pmd): pin trusted cwd on java -version spawn (covers PMD and CPD sub-engines) (CWE-427) PMD's RuntimeJavaVersionIdentifier is an independent copy of the SFGE identifier (shared by both the PMD and CPD sub-engines) and had the same CWE-427 flaw: 'java -version' was spawned with no cwd option, so on Windows the bare-'java' auto-detect fallback could resolve an attacker-committed java.exe from the inherited scanned-repo cwd ahead of the trusted PATH entry. Pin the spawn's cwd to the engine's installed module directory (__dirname). A single pin covers both sub-engines. Absolute-path and PATH-resolved java commands are unaffected, preserving behavior for legitimate java_command configs. Mirror of the SFGE fix. Adds a dedicated JavaVersionIdentifier.test.ts with regression tests (fake-java probe reports its spawn cwd; planted repo-local java sentinel) proving the child runs from the trusted dir and a repo-local java is never invoked. --- .../src/JavaVersionIdentifier.ts | 11 ++- .../test/JavaVersionIdentifier.test.ts | 74 +++++++++++++++++++ .../test-data/executable-scripts/fake-java.sh | 14 ++++ .../executable-scripts/planted-java-marker.sh | 8 ++ 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 packages/code-analyzer-pmd-engine/test/JavaVersionIdentifier.test.ts create mode 100755 packages/code-analyzer-pmd-engine/test/test-data/executable-scripts/fake-java.sh create mode 100755 packages/code-analyzer-pmd-engine/test/test-data/executable-scripts/planted-java-marker.sh diff --git a/packages/code-analyzer-pmd-engine/src/JavaVersionIdentifier.ts b/packages/code-analyzer-pmd-engine/src/JavaVersionIdentifier.ts index e33c164b..855e62c3 100644 --- a/packages/code-analyzer-pmd-engine/src/JavaVersionIdentifier.ts +++ b/packages/code-analyzer-pmd-engine/src/JavaVersionIdentifier.ts @@ -20,7 +20,16 @@ export class RuntimeJavaVersionIdentifier implements JavaVersionIdentifier { // If instead we used java --version then the output would look something like: // * (from Win10): "openjdk 14 2020-03-17\r\nOpenJDK Runtime Environment (build 14+36-1461)\r\nOpenJDK 64-Bit Server VM (build 14+36-1461, mixed mode, sharing)\r\n" // Notice it doesn't have the word "version" which is why we don't call "--version" but instead call "-version". - const childProcess: cp.ChildProcessWithoutNullStreams = cp.spawn(javaCommand, ['-version']); + // + // We pin the child's working directory to this engine's installed module directory (__dirname), + // never the inherited, attacker-controllable scanned-repo cwd. This identifier is shared by both + // the PMD and CPD sub-engines, so this single pin covers both. On Windows, spawning a bare command + // name like "java" (the auto-detect fallback) without pinning cwd lets the OS resolve a repo-local + // java/java.exe from the current directory ahead of the trusted PATH entry, executing attacker code + // (CWE-427 search-path element). Absolute-path and PATH-resolved java commands are unaffected, so + // behavior is preserved for legitimate configs. Mirrors the Flow engine's PythonCommandExecutor + // cwd pin. + const childProcess: cp.ChildProcessWithoutNullStreams = cp.spawn(javaCommand, ['-version'], {cwd: __dirname}); let stderr: string = ''; childProcess.stderr.on('data', (data: Buffer) => { diff --git a/packages/code-analyzer-pmd-engine/test/JavaVersionIdentifier.test.ts b/packages/code-analyzer-pmd-engine/test/JavaVersionIdentifier.test.ts new file mode 100644 index 00000000..3621bb1e --- /dev/null +++ b/packages/code-analyzer-pmd-engine/test/JavaVersionIdentifier.test.ts @@ -0,0 +1,74 @@ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import * as fsp from "node:fs/promises"; +import {RuntimeJavaVersionIdentifier} from "../src/JavaVersionIdentifier"; + +const EXECUTABLE_SCRIPTS_DIR: string = path.resolve(__dirname, 'test-data', 'executable-scripts'); +const PATH_TO_FAKE_JAVA: string = path.join(EXECUTABLE_SCRIPTS_DIR, 'fake-java.sh'); +const PATH_TO_PLANTED_JAVA_MARKER: string = path.join(EXECUTABLE_SCRIPTS_DIR, 'planted-java-marker.sh'); +// The fake-java probe records the cwd it was actually spawned with into this file (a fixed path alongside +// the script). We report through a file rather than reading the child's stdout because jest's node +// testEnvironment sandboxes process.env, so an env-var-based channel would not reach the spawned child. +const PATH_TO_SPAWN_CWD_REPORT: string = path.join(EXECUTABLE_SCRIPTS_DIR, 'spawn-cwd-report.txt'); +// The trust anchor: the engine's own installed module directory that identifyJavaVersion pins the java +// -version spawn's cwd to. This mirrors __dirname of the source module (JavaVersionIdentifier.ts). Under +// ts-jest the source runs from src (not dist), so the trusted directory resolves to the package's src dir. +const TRUSTED_DIR: string = fs.realpathSync(path.resolve(__dirname, '..', 'src')); + +describe('Security regression: java -version spawn is not satisfiable by a repo-local (cwd) executable', () => { + // Regression tests for the CWE-427 search-path-element RCE. This identifier is shared by both the PMD and + // CPD sub-engines. On Windows, spawning a bare command name (the "java" auto-detect fallback) without + // pinning cwd lets child_process resolve an attacker-committed java.exe from the inherited scanned-repo + // cwd ahead of the trusted PATH entry. Pinning the spawn's cwd to this engine's installed module directory + // (__dirname) closes that vector while leaving absolute-path and PATH-resolved java resolution unchanged. + // Mirrors the Flow engine test pattern (test/python/PythonCommandExecutor.test.ts). + + it('spawned java child runs from a trusted directory, not the inherited scanned-repo cwd', async () => { + const tempDir: string = await fsp.mkdtemp(path.join(os.tmpdir(), 'pmd-java-cwd-test-')); + const originalCwd: string = process.cwd(); + try { + await fsp.rm(PATH_TO_SPAWN_CWD_REPORT, {force: true}); + // Simulate the CLI being invoked from within the scanned (attacker-controlled) repo. + process.chdir(tempDir); + + await new RuntimeJavaVersionIdentifier().identifyJavaVersion(PATH_TO_FAKE_JAVA); + + const reportedCwd: string = (await fsp.readFile(PATH_TO_SPAWN_CWD_REPORT, {encoding: 'utf-8'})).trim(); + // The spawned child's cwd must be the trusted module directory, never the inherited scanned repo. + expect(fs.realpathSync(reportedCwd)).toEqual(TRUSTED_DIR); + expect(fs.realpathSync(reportedCwd)).not.toEqual(fs.realpathSync(tempDir)); + } finally { + process.chdir(originalCwd); + await fsp.rm(tempDir, {recursive: true, force: true}); + await fsp.rm(PATH_TO_SPAWN_CWD_REPORT, {force: true}); + } + }); + + it('a java executable planted in the scanned-repo cwd is never invoked', async () => { + const tempDir: string = await fsp.mkdtemp(path.join(os.tmpdir(), 'pmd-java-planted-test-')); + const sentinelFile: string = path.join(tempDir, 'PWNED.txt'); + const originalCwd: string = process.cwd(); + try { + const markerContents: string = await fsp.readFile(PATH_TO_PLANTED_JAVA_MARKER, {encoding: 'utf-8'}); + // Plant a hostile executable named exactly as the bare-command fallback would resolve on each OS. + const plantedNames: string[] = process.platform === 'win32' ? ['java', 'java.exe'] : ['java']; + for (const name of plantedNames) { + const plantedPath: string = path.join(tempDir, name); + await fsp.writeFile(plantedPath, markerContents, {encoding: 'utf-8', mode: 0o755}); + await fsp.chmod(plantedPath, 0o755); + } + process.chdir(tempDir); + + // Probe with the known-good fake java so the promise resolves regardless of the pin working; the + // assertion of interest is purely whether the planted repo-local executable was ever executed. + await new RuntimeJavaVersionIdentifier().identifyJavaVersion(PATH_TO_FAKE_JAVA); + + expect(fs.existsSync(sentinelFile)).toEqual(false); + } finally { + process.chdir(originalCwd); + await fsp.rm(tempDir, {recursive: true, force: true}); + await fsp.rm(PATH_TO_SPAWN_CWD_REPORT, {force: true}); + } + }); +}); diff --git a/packages/code-analyzer-pmd-engine/test/test-data/executable-scripts/fake-java.sh b/packages/code-analyzer-pmd-engine/test/test-data/executable-scripts/fake-java.sh new file mode 100755 index 00000000..771a5101 --- /dev/null +++ b/packages/code-analyzer-pmd-engine/test/test-data/executable-scripts/fake-java.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Fake "java" probe used by the CWE-427 regression tests. It records the working +# directory the child process was actually spawned with so the test can assert the +# spawn cwd is the trusted module directory rather than the inherited scanned-repo +# cwd. The cwd is written both to the file named by the CWD_REPORT_FILE env var (when +# it propagates) and to a report file alongside this script at a path the test can +# always resolve. It then prints a parseable `java -version` style line to STDERR and +# exits 0 so the version-probe logic under test resolves normally. +if [ -n "$CWD_REPORT_FILE" ]; then + pwd > "$CWD_REPORT_FILE" +fi +pwd > "$(dirname "$0")/spawn-cwd-report.txt" +echo 'openjdk version "11.0.6" 2020-01-14' 1>&2 +exit 0 diff --git a/packages/code-analyzer-pmd-engine/test/test-data/executable-scripts/planted-java-marker.sh b/packages/code-analyzer-pmd-engine/test/test-data/executable-scripts/planted-java-marker.sh new file mode 100755 index 00000000..57653401 --- /dev/null +++ b/packages/code-analyzer-pmd-engine/test/test-data/executable-scripts/planted-java-marker.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# Hostile stand-in for an attacker-committed repo-local `java`/`java.exe`. If this +# ever executes it means the java lookup was hijacked by the scanned-repo cwd, so +# it drops a PWNED sentinel next to itself as proof-of-execution. The regression +# test asserts this sentinel never appears. +echo 'PWNED' > "$(dirname "$0")/PWNED.txt" +echo 'openjdk version "11.0.6" 2020-01-14' 1>&2 +exit 0 From 474dd1bf0192daab5bfea1ad5e5fd9566e5d2e7a Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Sun, 23 Aug 2026 12:53:31 +0530 Subject: [PATCH 3/6] fix(test): make java -version cwd-pin regression tests cross-platform The new CWE-427 regression tests spawned .sh fake-java scripts as the java command. Windows cannot directly execute .sh files, so cp.spawn threw "spawn UNKNOWN" and the windows-latest CI job failed - the fix's own tests broke on the very OS the fix targets. identifyJavaVersion spawns the java command directly (no interpreter wrapper, unlike the Flow engine's python), so a fake-java probe can't be a portable script. Replace the script-based tests with a jest spy on cp.spawn asserting the spawn cwd is pinned to the trusted module directory (__dirname) - the exact mechanism that closes the vector - and it runs identically on every platform. Drop the now-unused .sh scripts and condense the verbose source/test comments. --- .../src/JavaVersionIdentifier.ts | 11 +-- .../test/JavaVersionIdentifier.test.ts | 87 +++++------------- .../test-data/executable-scripts/fake-java.sh | 14 --- .../executable-scripts/planted-java-marker.sh | 8 -- .../src/java-version-identifier.ts | 9 +- .../test/java-version-identifier.test.ts | 89 +++++-------------- .../test-data/executable-scripts/fake-java.sh | 14 --- .../executable-scripts/planted-java-marker.sh | 8 -- 8 files changed, 52 insertions(+), 188 deletions(-) delete mode 100755 packages/code-analyzer-pmd-engine/test/test-data/executable-scripts/fake-java.sh delete mode 100755 packages/code-analyzer-pmd-engine/test/test-data/executable-scripts/planted-java-marker.sh delete mode 100755 packages/code-analyzer-sfge-engine/test/test-data/executable-scripts/fake-java.sh delete mode 100755 packages/code-analyzer-sfge-engine/test/test-data/executable-scripts/planted-java-marker.sh diff --git a/packages/code-analyzer-pmd-engine/src/JavaVersionIdentifier.ts b/packages/code-analyzer-pmd-engine/src/JavaVersionIdentifier.ts index 855e62c3..023f44b3 100644 --- a/packages/code-analyzer-pmd-engine/src/JavaVersionIdentifier.ts +++ b/packages/code-analyzer-pmd-engine/src/JavaVersionIdentifier.ts @@ -21,14 +21,9 @@ export class RuntimeJavaVersionIdentifier implements JavaVersionIdentifier { // * (from Win10): "openjdk 14 2020-03-17\r\nOpenJDK Runtime Environment (build 14+36-1461)\r\nOpenJDK 64-Bit Server VM (build 14+36-1461, mixed mode, sharing)\r\n" // Notice it doesn't have the word "version" which is why we don't call "--version" but instead call "-version". // - // We pin the child's working directory to this engine's installed module directory (__dirname), - // never the inherited, attacker-controllable scanned-repo cwd. This identifier is shared by both - // the PMD and CPD sub-engines, so this single pin covers both. On Windows, spawning a bare command - // name like "java" (the auto-detect fallback) without pinning cwd lets the OS resolve a repo-local - // java/java.exe from the current directory ahead of the trusted PATH entry, executing attacker code - // (CWE-427 search-path element). Absolute-path and PATH-resolved java commands are unaffected, so - // behavior is preserved for legitimate configs. Mirrors the Flow engine's PythonCommandExecutor - // cwd pin. + // Pin cwd to this engine's install dir (__dirname), not the inherited scanned-repo cwd, so a + // repo-local java.exe can't shadow the real one on Windows (CWE-427). Absolute/PATH commands unaffected. + // Shared by the PMD and CPD sub-engines, so this single pin covers both. const childProcess: cp.ChildProcessWithoutNullStreams = cp.spawn(javaCommand, ['-version'], {cwd: __dirname}); let stderr: string = ''; diff --git a/packages/code-analyzer-pmd-engine/test/JavaVersionIdentifier.test.ts b/packages/code-analyzer-pmd-engine/test/JavaVersionIdentifier.test.ts index 3621bb1e..93d6bf8b 100644 --- a/packages/code-analyzer-pmd-engine/test/JavaVersionIdentifier.test.ts +++ b/packages/code-analyzer-pmd-engine/test/JavaVersionIdentifier.test.ts @@ -1,74 +1,33 @@ -import fs from "node:fs"; import path from "node:path"; -import os from "node:os"; -import * as fsp from "node:fs/promises"; +import cp from "node:child_process"; +import {EventEmitter} from "node:events"; import {RuntimeJavaVersionIdentifier} from "../src/JavaVersionIdentifier"; -const EXECUTABLE_SCRIPTS_DIR: string = path.resolve(__dirname, 'test-data', 'executable-scripts'); -const PATH_TO_FAKE_JAVA: string = path.join(EXECUTABLE_SCRIPTS_DIR, 'fake-java.sh'); -const PATH_TO_PLANTED_JAVA_MARKER: string = path.join(EXECUTABLE_SCRIPTS_DIR, 'planted-java-marker.sh'); -// The fake-java probe records the cwd it was actually spawned with into this file (a fixed path alongside -// the script). We report through a file rather than reading the child's stdout because jest's node -// testEnvironment sandboxes process.env, so an env-var-based channel would not reach the spawned child. -const PATH_TO_SPAWN_CWD_REPORT: string = path.join(EXECUTABLE_SCRIPTS_DIR, 'spawn-cwd-report.txt'); -// The trust anchor: the engine's own installed module directory that identifyJavaVersion pins the java -// -version spawn's cwd to. This mirrors __dirname of the source module (JavaVersionIdentifier.ts). Under -// ts-jest the source runs from src (not dist), so the trusted directory resolves to the package's src dir. -const TRUSTED_DIR: string = fs.realpathSync(path.resolve(__dirname, '..', 'src')); +// Under ts-jest the source runs from src/, so its __dirname (the cwd the spawn is pinned to) is this dir. +const TRUSTED_DIR: string = path.resolve(__dirname, '..', 'src'); -describe('Security regression: java -version spawn is not satisfiable by a repo-local (cwd) executable', () => { - // Regression tests for the CWE-427 search-path-element RCE. This identifier is shared by both the PMD and - // CPD sub-engines. On Windows, spawning a bare command name (the "java" auto-detect fallback) without - // pinning cwd lets child_process resolve an attacker-committed java.exe from the inherited scanned-repo - // cwd ahead of the trusted PATH entry. Pinning the spawn's cwd to this engine's installed module directory - // (__dirname) closes that vector while leaving absolute-path and PATH-resolved java resolution unchanged. - // Mirrors the Flow engine test pattern (test/python/PythonCommandExecutor.test.ts). - - it('spawned java child runs from a trusted directory, not the inherited scanned-repo cwd', async () => { - const tempDir: string = await fsp.mkdtemp(path.join(os.tmpdir(), 'pmd-java-cwd-test-')); - const originalCwd: string = process.cwd(); - try { - await fsp.rm(PATH_TO_SPAWN_CWD_REPORT, {force: true}); - // Simulate the CLI being invoked from within the scanned (attacker-controlled) repo. - process.chdir(tempDir); - - await new RuntimeJavaVersionIdentifier().identifyJavaVersion(PATH_TO_FAKE_JAVA); - - const reportedCwd: string = (await fsp.readFile(PATH_TO_SPAWN_CWD_REPORT, {encoding: 'utf-8'})).trim(); - // The spawned child's cwd must be the trusted module directory, never the inherited scanned repo. - expect(fs.realpathSync(reportedCwd)).toEqual(TRUSTED_DIR); - expect(fs.realpathSync(reportedCwd)).not.toEqual(fs.realpathSync(tempDir)); - } finally { - process.chdir(originalCwd); - await fsp.rm(tempDir, {recursive: true, force: true}); - await fsp.rm(PATH_TO_SPAWN_CWD_REPORT, {force: true}); - } +function stubJavaProcess(stderrLine: string, exitCode: number = 0): cp.ChildProcessWithoutNullStreams { + const child = Object.assign(new EventEmitter(), {stderr: new EventEmitter()}); + process.nextTick(() => { + child.stderr.emit('data', Buffer.from(stderrLine)); + child.emit('exit', exitCode); }); + return child as unknown as cp.ChildProcessWithoutNullStreams; +} + +describe('RuntimeJavaVersionIdentifier CWE-427 regression', () => { + afterEach(() => jest.restoreAllMocks()); - it('a java executable planted in the scanned-repo cwd is never invoked', async () => { - const tempDir: string = await fsp.mkdtemp(path.join(os.tmpdir(), 'pmd-java-planted-test-')); - const sentinelFile: string = path.join(tempDir, 'PWNED.txt'); - const originalCwd: string = process.cwd(); - try { - const markerContents: string = await fsp.readFile(PATH_TO_PLANTED_JAVA_MARKER, {encoding: 'utf-8'}); - // Plant a hostile executable named exactly as the bare-command fallback would resolve on each OS. - const plantedNames: string[] = process.platform === 'win32' ? ['java', 'java.exe'] : ['java']; - for (const name of plantedNames) { - const plantedPath: string = path.join(tempDir, name); - await fsp.writeFile(plantedPath, markerContents, {encoding: 'utf-8', mode: 0o755}); - await fsp.chmod(plantedPath, 0o755); - } - process.chdir(tempDir); + // The java -version spawn must run from a trusted directory; inheriting the scanned-repo cwd would let a + // repo-local java.exe shadow the real one on Windows. We assert the cwd is pinned instead of spawning a + // real fake-java, since a directly-spawned command cannot be a portable script across OSes. + it('pins the java -version spawn cwd to the trusted module directory', async () => { + const spawnSpy = jest.spyOn(cp, 'spawn') + .mockImplementation(() => stubJavaProcess('openjdk version "11.0.6" 2020-01-14')); - // Probe with the known-good fake java so the promise resolves regardless of the pin working; the - // assertion of interest is purely whether the planted repo-local executable was ever executed. - await new RuntimeJavaVersionIdentifier().identifyJavaVersion(PATH_TO_FAKE_JAVA); + const version = await new RuntimeJavaVersionIdentifier().identifyJavaVersion('java'); - expect(fs.existsSync(sentinelFile)).toEqual(false); - } finally { - process.chdir(originalCwd); - await fsp.rm(tempDir, {recursive: true, force: true}); - await fsp.rm(PATH_TO_SPAWN_CWD_REPORT, {force: true}); - } + expect(version?.toString()).toEqual('11.0.6'); + expect(spawnSpy).toHaveBeenCalledWith('java', ['-version'], {cwd: TRUSTED_DIR}); }); }); diff --git a/packages/code-analyzer-pmd-engine/test/test-data/executable-scripts/fake-java.sh b/packages/code-analyzer-pmd-engine/test/test-data/executable-scripts/fake-java.sh deleted file mode 100755 index 771a5101..00000000 --- a/packages/code-analyzer-pmd-engine/test/test-data/executable-scripts/fake-java.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/sh -# Fake "java" probe used by the CWE-427 regression tests. It records the working -# directory the child process was actually spawned with so the test can assert the -# spawn cwd is the trusted module directory rather than the inherited scanned-repo -# cwd. The cwd is written both to the file named by the CWD_REPORT_FILE env var (when -# it propagates) and to a report file alongside this script at a path the test can -# always resolve. It then prints a parseable `java -version` style line to STDERR and -# exits 0 so the version-probe logic under test resolves normally. -if [ -n "$CWD_REPORT_FILE" ]; then - pwd > "$CWD_REPORT_FILE" -fi -pwd > "$(dirname "$0")/spawn-cwd-report.txt" -echo 'openjdk version "11.0.6" 2020-01-14' 1>&2 -exit 0 diff --git a/packages/code-analyzer-pmd-engine/test/test-data/executable-scripts/planted-java-marker.sh b/packages/code-analyzer-pmd-engine/test/test-data/executable-scripts/planted-java-marker.sh deleted file mode 100755 index 57653401..00000000 --- a/packages/code-analyzer-pmd-engine/test/test-data/executable-scripts/planted-java-marker.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh -# Hostile stand-in for an attacker-committed repo-local `java`/`java.exe`. If this -# ever executes it means the java lookup was hijacked by the scanned-repo cwd, so -# it drops a PWNED sentinel next to itself as proof-of-execution. The regression -# test asserts this sentinel never appears. -echo 'PWNED' > "$(dirname "$0")/PWNED.txt" -echo 'openjdk version "11.0.6" 2020-01-14' 1>&2 -exit 0 diff --git a/packages/code-analyzer-sfge-engine/src/java-version-identifier.ts b/packages/code-analyzer-sfge-engine/src/java-version-identifier.ts index c3f405b3..fc03802e 100644 --- a/packages/code-analyzer-sfge-engine/src/java-version-identifier.ts +++ b/packages/code-analyzer-sfge-engine/src/java-version-identifier.ts @@ -21,13 +21,8 @@ export class RuntimeJavaVersionIdentifier implements JavaVersionIdentifier { // * (from Win10): "openjdk 14 2020-03-17\r\nOpenJDK Runtime Environment (build 14+36-1461)\r\nOpenJDK 64-Bit Server VM (build 14+36-1461, mixed mode, sharing)\r\n" // Notice it doesn't have the word "version" which is why we don't call "--version" but instead call "-version". // - // We pin the child's working directory to this engine's installed module directory (__dirname), - // never the inherited, attacker-controllable scanned-repo cwd. On Windows, spawning a bare command - // name like "java" (the auto-detect fallback in config.ts) without pinning cwd lets the OS resolve - // a repo-local java/java.exe from the current directory ahead of the trusted PATH entry, executing - // attacker code (CWE-427 search-path element). Absolute-path and PATH-resolved java commands are - // unaffected, so behavior is preserved for legitimate configs. Mirrors the Flow engine's - // PythonCommandExecutor cwd pin. + // Pin cwd to this engine's install dir (__dirname), not the inherited scanned-repo cwd, so a + // repo-local java.exe can't shadow the real one on Windows (CWE-427). Absolute/PATH commands unaffected. const childProcess: cp.ChildProcessWithoutNullStreams = cp.spawn(javaCommand, ['-version'], {cwd: __dirname}); let stderr: string = ''; diff --git a/packages/code-analyzer-sfge-engine/test/java-version-identifier.test.ts b/packages/code-analyzer-sfge-engine/test/java-version-identifier.test.ts index fe8efc79..c073f806 100644 --- a/packages/code-analyzer-sfge-engine/test/java-version-identifier.test.ts +++ b/packages/code-analyzer-sfge-engine/test/java-version-identifier.test.ts @@ -1,21 +1,20 @@ -import fs from "node:fs"; import path from "node:path"; -import os from "node:os"; -import * as fsp from "node:fs/promises"; +import cp from "node:child_process"; +import {EventEmitter} from "node:events"; import {SemVer} from "semver"; import {_extractJavaVersionFrom, RuntimeJavaVersionIdentifier} from "../src/java-version-identifier"; -const EXECUTABLE_SCRIPTS_DIR: string = path.resolve(__dirname, 'test-data', 'executable-scripts'); -const PATH_TO_FAKE_JAVA: string = path.join(EXECUTABLE_SCRIPTS_DIR, 'fake-java.sh'); -const PATH_TO_PLANTED_JAVA_MARKER: string = path.join(EXECUTABLE_SCRIPTS_DIR, 'planted-java-marker.sh'); -// The fake-java probe records the cwd it was actually spawned with into this file (a fixed path alongside -// the script). We report through a file rather than reading the child's stdout because jest's node -// testEnvironment sandboxes process.env, so an env-var-based channel would not reach the spawned child. -const PATH_TO_SPAWN_CWD_REPORT: string = path.join(EXECUTABLE_SCRIPTS_DIR, 'spawn-cwd-report.txt'); -// The trust anchor: the engine's own installed module directory that identifyJavaVersion pins the java -// -version spawn's cwd to. This mirrors __dirname of the source module (java-version-identifier.ts). Under -// ts-jest the source runs from src (not dist), so the trusted directory resolves to the package's src dir. -const TRUSTED_DIR: string = fs.realpathSync(path.resolve(__dirname, '..', 'src')); +// Under ts-jest the source runs from src/, so its __dirname (the cwd the spawn is pinned to) is this dir. +const TRUSTED_DIR: string = path.resolve(__dirname, '..', 'src'); + +function stubJavaProcess(stderrLine: string, exitCode: number = 0): cp.ChildProcessWithoutNullStreams { + const child = Object.assign(new EventEmitter(), {stderr: new EventEmitter()}); + process.nextTick(() => { + child.stderr.emit('data', Buffer.from(stderrLine)); + child.emit('exit', exitCode); + }); + return child as unknown as cp.ChildProcessWithoutNullStreams; +} describe('Test for _extractJavaVersionFrom helper', () => { type VERSION_CASE = {description: string, input: string, expected: SemVer}; @@ -56,59 +55,19 @@ describe('Test for _extractJavaVersionFrom helper', () => { }); }); -describe('Security regression: java -version spawn is not satisfiable by a repo-local (cwd) executable', () => { - // Regression tests for the CWE-427 search-path-element RCE. On Windows, spawning a bare command name - // (the "java" auto-detect fallback) without pinning cwd lets child_process resolve an attacker-committed - // java.exe from the inherited scanned-repo cwd ahead of the trusted PATH entry. Pinning the spawn's cwd - // to this engine's installed module directory (__dirname) closes that vector while leaving absolute-path - // and PATH-resolved java resolution unchanged. Mirrors the Flow engine test pattern - // (test/python/PythonCommandExecutor.test.ts). - - it('spawned java child runs from a trusted directory, not the inherited scanned-repo cwd', async () => { - const tempDir: string = await fsp.mkdtemp(path.join(os.tmpdir(), 'sfge-java-cwd-test-')); - const originalCwd: string = process.cwd(); - try { - await fsp.rm(PATH_TO_SPAWN_CWD_REPORT, {force: true}); - // Simulate the CLI being invoked from within the scanned (attacker-controlled) repo. - process.chdir(tempDir); - - await new RuntimeJavaVersionIdentifier().identifyJavaVersion(PATH_TO_FAKE_JAVA); +describe('RuntimeJavaVersionIdentifier CWE-427 regression', () => { + afterEach(() => jest.restoreAllMocks()); - const reportedCwd: string = (await fsp.readFile(PATH_TO_SPAWN_CWD_REPORT, {encoding: 'utf-8'})).trim(); - // The spawned child's cwd must be the trusted module directory, never the inherited scanned repo. - expect(fs.realpathSync(reportedCwd)).toEqual(TRUSTED_DIR); - expect(fs.realpathSync(reportedCwd)).not.toEqual(fs.realpathSync(tempDir)); - } finally { - process.chdir(originalCwd); - await fsp.rm(tempDir, {recursive: true, force: true}); - await fsp.rm(PATH_TO_SPAWN_CWD_REPORT, {force: true}); - } - }); + // The java -version spawn must run from a trusted directory; inheriting the scanned-repo cwd would let a + // repo-local java.exe shadow the real one on Windows. We assert the cwd is pinned instead of spawning a + // real fake-java, since a directly-spawned command cannot be a portable script across OSes. + it('pins the java -version spawn cwd to the trusted module directory', async () => { + const spawnSpy = jest.spyOn(cp, 'spawn') + .mockImplementation(() => stubJavaProcess('openjdk version "11.0.6" 2020-01-14')); - it('a java executable planted in the scanned-repo cwd is never invoked', async () => { - const tempDir: string = await fsp.mkdtemp(path.join(os.tmpdir(), 'sfge-java-planted-test-')); - const sentinelFile: string = path.join(tempDir, 'PWNED.txt'); - const originalCwd: string = process.cwd(); - try { - const markerContents: string = await fsp.readFile(PATH_TO_PLANTED_JAVA_MARKER, {encoding: 'utf-8'}); - // Plant a hostile executable named exactly as the bare-command fallback would resolve on each OS. - const plantedNames: string[] = process.platform === 'win32' ? ['java', 'java.exe'] : ['java']; - for (const name of plantedNames) { - const plantedPath: string = path.join(tempDir, name); - await fsp.writeFile(plantedPath, markerContents, {encoding: 'utf-8', mode: 0o755}); - await fsp.chmod(plantedPath, 0o755); - } - process.chdir(tempDir); + const version = await new RuntimeJavaVersionIdentifier().identifyJavaVersion('java'); - // Probe with the known-good fake java so the promise resolves regardless of the pin working; the - // assertion of interest is purely whether the planted repo-local executable was ever executed. - await new RuntimeJavaVersionIdentifier().identifyJavaVersion(PATH_TO_FAKE_JAVA); - - expect(fs.existsSync(sentinelFile)).toEqual(false); - } finally { - process.chdir(originalCwd); - await fsp.rm(tempDir, {recursive: true, force: true}); - await fsp.rm(PATH_TO_SPAWN_CWD_REPORT, {force: true}); - } + expect(version?.toString()).toEqual('11.0.6'); + expect(spawnSpy).toHaveBeenCalledWith('java', ['-version'], {cwd: TRUSTED_DIR}); }); }); diff --git a/packages/code-analyzer-sfge-engine/test/test-data/executable-scripts/fake-java.sh b/packages/code-analyzer-sfge-engine/test/test-data/executable-scripts/fake-java.sh deleted file mode 100755 index 771a5101..00000000 --- a/packages/code-analyzer-sfge-engine/test/test-data/executable-scripts/fake-java.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/sh -# Fake "java" probe used by the CWE-427 regression tests. It records the working -# directory the child process was actually spawned with so the test can assert the -# spawn cwd is the trusted module directory rather than the inherited scanned-repo -# cwd. The cwd is written both to the file named by the CWD_REPORT_FILE env var (when -# it propagates) and to a report file alongside this script at a path the test can -# always resolve. It then prints a parseable `java -version` style line to STDERR and -# exits 0 so the version-probe logic under test resolves normally. -if [ -n "$CWD_REPORT_FILE" ]; then - pwd > "$CWD_REPORT_FILE" -fi -pwd > "$(dirname "$0")/spawn-cwd-report.txt" -echo 'openjdk version "11.0.6" 2020-01-14' 1>&2 -exit 0 diff --git a/packages/code-analyzer-sfge-engine/test/test-data/executable-scripts/planted-java-marker.sh b/packages/code-analyzer-sfge-engine/test/test-data/executable-scripts/planted-java-marker.sh deleted file mode 100755 index 57653401..00000000 --- a/packages/code-analyzer-sfge-engine/test/test-data/executable-scripts/planted-java-marker.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh -# Hostile stand-in for an attacker-committed repo-local `java`/`java.exe`. If this -# ever executes it means the java lookup was hijacked by the scanned-repo cwd, so -# it drops a PWNED sentinel next to itself as proof-of-execution. The regression -# test asserts this sentinel never appears. -echo 'PWNED' > "$(dirname "$0")/PWNED.txt" -echo 'openjdk version "11.0.6" 2020-01-14' 1>&2 -exit 0 From d1ac8da5feeaaf5fa6d877127c61c2acf7b7d86a Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Mon, 24 Aug 2026 15:35:53 +0530 Subject: [PATCH 4/6] fix(engine-api): pin trusted cwd on shared JavaCommandExecutor spawn (CWE-427) The version-probe fix in this PR left the actual rule-listing/execution spawn (JavaCommandExecutor.exec) inheriting the scanned-repo cwd, so on Windows a repo-local java.exe still shadowed the real java for PMD/CPD/SFGE rule work. Pin cwd to __dirname; every java arg (classpaths + I/O files) is absolute, so this is behavior-preserving. Mirrors the Flow-engine cwd-shadowing fix in #495 (W-23791879). Adds a spawn-cwd regression test plus a skipped windows-latest integration stub. Ref W-23949583. --- .../src/utils/java-utils.ts | 8 ++- .../test/utils/utils.test.ts | 62 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/packages/code-analyzer-engine-api/src/utils/java-utils.ts b/packages/code-analyzer-engine-api/src/utils/java-utils.ts index b050d5d9..7b679068 100644 --- a/packages/code-analyzer-engine-api/src/utils/java-utils.ts +++ b/packages/code-analyzer-engine-api/src/utils/java-utils.ts @@ -26,7 +26,13 @@ export class JavaCommandExecutor { this.emitLogEvent(LogLevel.Fine, `Calling command: ${this.javaCommand} ` + allJavaArgs.map(arg => arg.startsWith('-') ? arg : `"${arg}"`).join(' ')); - const javaProcess: ChildProcessWithoutNullStreams = spawn(this.javaCommand, allJavaArgs); + // Pin cwd to this module's trusted install dir (__dirname), not the inherited scanned-repo cwd, so a + // repo-local java.exe can't shadow the real one on Windows, where a bare command name resolves + // cwd-before-PATH (CWE-427). This is the shared executor for the PMD, CPD, and SFGE engines, so this + // single pin protects the actual rule-listing/execution path (not just the version probe) for all three. + // Every java arg (classpaths and I/O files) is absolute, so pinning cwd is behavior-preserving. + // Mirrors the Flow-engine cwd-shadowing fix in PR #495 (W-23791879). + const javaProcess: ChildProcessWithoutNullStreams = spawn(this.javaCommand, allJavaArgs, {cwd: __dirname}); javaProcess.stdout.on('data', (data: Buffer) => { const msg: string = data.toString().trim(); diff --git a/packages/code-analyzer-engine-api/test/utils/utils.test.ts b/packages/code-analyzer-engine-api/test/utils/utils.test.ts index f2670aa8..455e8c25 100644 --- a/packages/code-analyzer-engine-api/test/utils/utils.test.ts +++ b/packages/code-analyzer-engine-api/test/utils/utils.test.ts @@ -1,3 +1,8 @@ +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs"; +import cp from "node:child_process"; +import {EventEmitter} from "node:events"; import {FixedClock, indent, JavaCommandExecutor, RealClock} from "../../src/utils"; jest.setTimeout(30_000); @@ -38,6 +43,63 @@ describe('Tests for JavaCommandExecutor', () => { }); }); +function stubJavaExecProcess(exitCode: number = 0): cp.ChildProcessWithoutNullStreams { + // Mimics the surface JavaCommandExecutor.exec() consumes: stdout/stderr streams plus a 'close' event. + const child = Object.assign(new EventEmitter(), {stdout: new EventEmitter(), stderr: new EventEmitter()}); + process.nextTick(() => child.emit('close', exitCode)); + return child as unknown as cp.ChildProcessWithoutNullStreams; +} + +describe('JavaCommandExecutor CWE-427 cwd pinning', () => { + // Under ts-jest the source runs from src/, so java-utils.ts's __dirname (the cwd the spawn is pinned to) is + // the src/utils dir. This is the shared executor for the actual PMD, CPD, and SFGE rule-listing/execution + // path (not just the version probe). + const TRUSTED_DIR: string = path.resolve(__dirname, '..', '..', 'src', 'utils'); + + afterEach(() => jest.restoreAllMocks()); + + // Regression guard: inheriting the scanned-repo cwd would let a repo-local java.exe shadow the real one on + // Windows, where a bare command name resolves cwd-before-PATH. We assert the spawn cwd is pinned to the trusted + // module dir rather than the inherited process cwd. (OS-level shadowing is proven by the Windows integration + // test below; a directly-spawned command cannot be a portable fake-java script across OSes.) + it('pins the java spawn cwd to the trusted engine-api module directory', async () => { + const spawnSpy = jest.spyOn(cp, 'spawn').mockImplementation(() => stubJavaExecProcess(0)); + + await new JavaCommandExecutor('java').exec(['-version']); + + expect(spawnSpy).toHaveBeenCalledWith('java', ['-version'], {cwd: TRUSTED_DIR}); + // The pin must not be the inherited (scanned-repo) cwd — that is the vulnerable behavior. + expect(TRUSTED_DIR).not.toEqual(process.cwd()); + }); + + // STUB (#2 of the review follow-up) — end-to-end proof of the security property, not just the argument. + // Intentionally skipped: enable and validate on the windows-latest CI runner (Java 11 is already provisioned + // there via actions/setup-java). Two caveats to resolve before un-skipping: + // 1. A faithful attack plants a real `java.exe` (Windows resolves bare names cwd-before-PATH). Creating a + // genuine .exe portably in a test is nontrivial; a `java.cmd`/`.bat` is a weaker proxy AND, since Node + // 18.20.2/20.12.2, spawning .bat/.cmd without `shell:true` throws EINVAL rather than executing — so the + // proxy must be chosen carefully or the assertion will pass for the wrong reason. + // 2. The runner must have a working PATH `java` and no *_HOME so the bare-'java' path is exercised. + // Mechanism once enabled: plant a hostile `java` in a temp dir, chdir there (simulating a scanned repo), + // exec via JavaCommandExecutor, and assert the sentinel was never written — i.e. our cwd pin routed the child + // to the trusted dir and the planted binary never ran. Removing the {cwd:__dirname} pin must fail this test. + it.skip('does not execute a repo-local java planted in the scanned-repo cwd (Windows-only; see comment)', async () => { + const attackDir: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'cwd-shadow-')); + const sentinel: string = path.join(attackDir, 'PWNED.txt'); + await fs.promises.writeFile(path.join(attackDir, 'java.cmd'), + `@echo off\r\n> "${sentinel}" echo pwned\r\nexit /b 0\r\n`); + const originalCwd: string = process.cwd(); + try { + process.chdir(attackDir); + await new JavaCommandExecutor('java').exec(['-version']); + } finally { + process.chdir(originalCwd); + await fs.promises.rm(attackDir, {recursive: true, force: true}); + } + expect(fs.existsSync(sentinel)).toBe(false); + }); +}); + describe('Test for indent', () => { it('When using standard indentation then four spaces should be used', () => { expect(indent(`This is a test\nof a multiline\nmessage`)).toEqual( From fe8ccf052b01f01e0e4352f5849a42e59c6cc64d Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Mon, 24 Aug 2026 15:36:20 +0530 Subject: [PATCH 5/6] test(pmd): cover _extractJavaVersionFrom in JavaVersionIdentifier suite The new pmd JavaVersionIdentifier test file only exercised the CWE-427 cwd pin; the exported version-parsing helper had zero coverage (unlike the sfge copy, whose suite already tests it). Add the same parse cases so pmd's copy is covered too. --- .../test/JavaVersionIdentifier.test.ts | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/code-analyzer-pmd-engine/test/JavaVersionIdentifier.test.ts b/packages/code-analyzer-pmd-engine/test/JavaVersionIdentifier.test.ts index 93d6bf8b..cf8be0be 100644 --- a/packages/code-analyzer-pmd-engine/test/JavaVersionIdentifier.test.ts +++ b/packages/code-analyzer-pmd-engine/test/JavaVersionIdentifier.test.ts @@ -1,7 +1,8 @@ import path from "node:path"; import cp from "node:child_process"; import {EventEmitter} from "node:events"; -import {RuntimeJavaVersionIdentifier} from "../src/JavaVersionIdentifier"; +import {SemVer} from "semver"; +import {_extractJavaVersionFrom, RuntimeJavaVersionIdentifier} from "../src/JavaVersionIdentifier"; // Under ts-jest the source runs from src/, so its __dirname (the cwd the spawn is pinned to) is this dir. const TRUSTED_DIR: string = path.resolve(__dirname, '..', 'src'); @@ -15,6 +16,45 @@ function stubJavaProcess(stderrLine: string, exitCode: number = 0): cp.ChildProc return child as unknown as cp.ChildProcessWithoutNullStreams; } +describe('Test for _extractJavaVersionFrom helper', () => { + type VERSION_CASE = {description: string, input: string, expected: SemVer}; + const versionCases: VERSION_CASE[] = [ + { + description: 'v11_linux', + input: 'openjdk version "11.0.6" 2020-01-14 LTS\nOpenJDK Runtime Environment Zulu11.37+17-CA (build 11.0.6+10-LTS)\nOpenJDK 64-Bit Server VM Zulu11.37+17-CA (build 11.0.6+10-LTS, mixed mode)\n', + expected: new SemVer('11.0.6') + }, + { + description: 'v8_mac', + input: 'openjdk version "1.8.0_172"\nOpenJDK Runtime Environment (Zulu 8.30.0.2-macosx) (build 1.8.0_172-b01)\nOpenJDK 64-Bit Server VM (Zulu 8.30.0.2-macosx) (build 25.172-b01, mixed mode)\n', + expected: new SemVer('1.8.0') + }, + { + description: 'v12_linux', + input: 'java version "12.0.1" 2019-04-16\nJava(TM) SE Runtime Environment (build 12.0.1+12)\nJava HotSpot(TM) 64-Bit Server VM (build 12.0.1+12, mixed mode, sharing)', + expected: new SemVer('12.0.1') + }, + { // This comes from https://github.com/forcedotcom/sfdx-scanner/issues/1453 + description: 'v17_with_java_options', + input: 'Picked up _JAVA_OPTIONS: -Xmx5g\njava version "17.0.11" 2024-04-16 LTS\nJava(TM) SE Runtime Environment (build 17.0.11+7-LTS-207)', + expected: new SemVer('17.0.11') + }, + { // This type of output typically comes from "java --version" instead of "java -version" but we will try to support it as well + description: 'v14_windows', + input: 'openjdk 14 2020-03-17\r\nOpenJDK Runtime Environment (build 14+36-1461)\r\nOpenJDK 64-Bit Server VM (build 14+36-1461, mixed mode, sharing)\r\n', + expected: new SemVer('14.0.0') + } + ]; + it.each(versionCases)('For version $description, make sure _extractJavaVersionFrom returns expected version', async (caseObj: VERSION_CASE) => { + const version: SemVer = _extractJavaVersionFrom(caseObj.input)!; + expect(version.toString()).toEqual(caseObj.expected.toString()); + }); + + it('Check that _extractJavaVersionFrom returns null if given garbage without version info', async () => { + expect(_extractJavaVersionFrom('this is garbage')).toEqual(null); + }); +}); + describe('RuntimeJavaVersionIdentifier CWE-427 regression', () => { afterEach(() => jest.restoreAllMocks()); From 8add761169790378f4555feb32259db1471c12e5 Mon Sep 17 00:00:00 2001 From: Nikhil Mittal Date: Mon, 24 Aug 2026 16:42:07 +0530 Subject: [PATCH 6/6] hard java check --- .../test/utils/utils.test.ts | 46 +++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/packages/code-analyzer-engine-api/test/utils/utils.test.ts b/packages/code-analyzer-engine-api/test/utils/utils.test.ts index 455e8c25..be24481a 100644 --- a/packages/code-analyzer-engine-api/test/utils/utils.test.ts +++ b/packages/code-analyzer-engine-api/test/utils/utils.test.ts @@ -72,31 +72,41 @@ describe('JavaCommandExecutor CWE-427 cwd pinning', () => { expect(TRUSTED_DIR).not.toEqual(process.cwd()); }); - // STUB (#2 of the review follow-up) — end-to-end proof of the security property, not just the argument. - // Intentionally skipped: enable and validate on the windows-latest CI runner (Java 11 is already provisioned - // there via actions/setup-java). Two caveats to resolve before un-skipping: - // 1. A faithful attack plants a real `java.exe` (Windows resolves bare names cwd-before-PATH). Creating a - // genuine .exe portably in a test is nontrivial; a `java.cmd`/`.bat` is a weaker proxy AND, since Node - // 18.20.2/20.12.2, spawning .bat/.cmd without `shell:true` throws EINVAL rather than executing — so the - // proxy must be chosen carefully or the assertion will pass for the wrong reason. - // 2. The runner must have a working PATH `java` and no *_HOME so the bare-'java' path is exercised. - // Mechanism once enabled: plant a hostile `java` in a temp dir, chdir there (simulating a scanned repo), - // exec via JavaCommandExecutor, and assert the sentinel was never written — i.e. our cwd pin routed the child - // to the trusted dir and the planted binary never ran. Removing the {cwd:__dirname} pin must fail this test. - it.skip('does not execute a repo-local java planted in the scanned-repo cwd (Windows-only; see comment)', async () => { + // End-to-end proof of the security property on the only OS where it is reachable. Windows resolves a bare + // command name cwd-before-PATH, so a repo-local java.exe can shadow the real one; macOS/Linux use PATH only + // (execvp) and never consult cwd, so this test is skipped there. The CI matrix runs the windows-latest leg, + // which provisions a real temurin java on PATH via actions/setup-java. + // + // We plant a genuine java.exe (a copy of a harmless system .exe) in an attacker-controlled dir and chdir there + // to simulate scanning that repo. A real .exe is required: on Node >= 18.20.2/20.12.2 (CI uses Node 20) + // spawning a .bat/.cmd without shell:true throws EINVAL, so a .cmd proxy would never run and the test would + // pass for the wrong reason. Detection is by output: `java --version` prints its banner to stdout and exits 0 + // on JDK 9+, which the planted hostname.exe cannot reproduce. With the {cwd:__dirname} pin the child resolves + // to the real PATH java; remove the pin and the planted exe runs instead, failing both assertions below. + const itOnWindows = process.platform === 'win32' ? it : it.skip; + itOnWindows('does not execute a repo-local java.exe planted in the scanned-repo cwd', async () => { const attackDir: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'cwd-shadow-')); - const sentinel: string = path.join(attackDir, 'PWNED.txt'); - await fs.promises.writeFile(path.join(attackDir, 'java.cmd'), - `@echo off\r\n> "${sentinel}" echo pwned\r\nexit /b 0\r\n`); + // A genuine .exe named exactly "java.exe": Windows resolves a bare "java" to it when the cwd is searched. + await fs.promises.copyFile( + path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'hostname.exe'), + path.join(attackDir, 'java.exe')); + const originalCwd: string = process.cwd(); + let stdout: string = ''; + let execError: string = ''; try { - process.chdir(attackDir); - await new JavaCommandExecutor('java').exec(['-version']); + process.chdir(attackDir); // simulate the CLI being invoked from inside the untrusted repo + await new JavaCommandExecutor('java').exec(['--version'], [], line => { stdout += line + '\n'; }); + } catch (err) { + execError = (err as Error).message; } finally { process.chdir(originalCwd); await fs.promises.rm(attackDir, {recursive: true, force: true}); } - expect(fs.existsSync(sentinel)).toBe(false); + + // The real PATH java must have run (its version banner reached stdout) and not the planted hostname.exe. + expect(stdout.toLowerCase()).toMatch(/java|jdk|openjdk|runtime|hotspot/); + expect(execError).toEqual(''); }); });