From 4fe35856dbf52783ec25112ff840d8b3bc772e5a Mon Sep 17 00:00:00 2001 From: Sebastion Date: Tue, 31 Mar 2026 07:22:46 +0100 Subject: [PATCH] fix: use execFile instead of exec to prevent command injection in static analysis Replace child_process.exec (which spawns a shell) with child_process.execFile (which does not) in the static analysis runner. This prevents shell metacharacters in agent-supplied targetPath from being interpreted, closing a CWE-78 command injection vector. The runCommand function now passes cmd and args as separate parameters to execFileAsync, so arguments are never concatenated into a shell string. --- src/tools/static-analysis.ts | 7 +- test/main/static-analysis-injection.test.mjs | 102 +++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 test/main/static-analysis-injection.test.mjs diff --git a/src/tools/static-analysis.ts b/src/tools/static-analysis.ts index 48bf883..208e427 100644 --- a/src/tools/static-analysis.ts +++ b/src/tools/static-analysis.ts @@ -1,12 +1,12 @@ // Static analysis runner using native linters and compilers // Delegates dead code detection to deterministic tools, not LLM guessing -import { exec } from "child_process"; +import { execFile } from "child_process"; import { stat } from "fs/promises"; import { resolve, extname } from "path"; import { promisify } from "util"; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); export interface StaticAnalysisOptions { rootDir: string; @@ -29,9 +29,8 @@ const LINTER_MAP: Record = { }; async function runCommand(cmd: string, args: string[], cwd: string): Promise { - const fullCmd = `${cmd} ${args.join(" ")}`; try { - const { stdout, stderr } = await execAsync(fullCmd, { cwd, timeout: 30000, maxBuffer: 1024 * 512 }); + const { stdout, stderr } = await execFileAsync(cmd, args, { cwd, timeout: 30000, maxBuffer: 1024 * 512 }); return { tool: cmd, output: (stdout + stderr).trim(), exitCode: 0 }; } catch (err: any) { return { tool: cmd, output: (err.stdout ?? "") + (err.stderr ?? ""), exitCode: err.code ?? 1 }; diff --git a/test/main/static-analysis-injection.test.mjs b/test/main/static-analysis-injection.test.mjs new file mode 100644 index 0000000..3e41a90 --- /dev/null +++ b/test/main/static-analysis-injection.test.mjs @@ -0,0 +1,102 @@ +// Test: CWE-78 command injection via targetPath in static analysis +// Verifies that shell metacharacters in targetPath cannot be used for injection + +import { describe, it, before, after } from "node:test"; +import assert from "node:assert"; +import { mkdir, writeFile, rm, readFile } from "fs/promises"; +import { resolve, join } from "path"; + +const { runStaticAnalysis } = + await import("../../build/tools/static-analysis.js"); + +const FIXTURE = resolve("test/_injection_fixtures"); +const SENTINEL = join(FIXTURE, "pwned.txt"); + +before(async () => { + await mkdir(FIXTURE, { recursive: true }); + await writeFile( + join(FIXTURE, "safe.py"), + "# Safe python file\n# FEATURE: test\nprint('hello')\n", + ); +}); + +after(async () => { + await rm(FIXTURE, { recursive: true, force: true }); +}); + +describe("CWE-78: command injection via targetPath", () => { + it("should not execute injected commands via $() with .py extension", async () => { + // This payload ends in .py so it matches the Python linter + // The $() will be interpreted by the shell in exec() + const maliciousPath = `$(echo INJECTED > ${SENTINEL}).py`; + try { + await runStaticAnalysis({ rootDir: FIXTURE, targetPath: maliciousPath }); + } catch { + // errors are acceptable – injection must not succeed + } + + let injected = false; + try { + await readFile(SENTINEL, "utf-8"); + injected = true; + } catch { + injected = false; + } + assert.strictEqual(injected, false, "Command injection via $() succeeded – sentinel file was created"); + }); + + it("should not execute injected commands via backticks with .py extension", async () => { + const maliciousPath = "`echo INJECTED > " + SENTINEL + "`.py"; + try { + await runStaticAnalysis({ rootDir: FIXTURE, targetPath: maliciousPath }); + } catch { + // errors are acceptable – injection must not succeed + } + + let injected = false; + try { + await readFile(SENTINEL, "utf-8"); + injected = true; + } catch { + injected = false; + } + assert.strictEqual(injected, false, "Command injection via backticks succeeded – sentinel file was created"); + }); + + it("should not execute injected commands via semicolon ending with .py", async () => { + // Craft: foo; echo INJECTED > sentinel; echo.py + const maliciousPath = `foo; echo INJECTED > ${SENTINEL}; echo.py`; + try { + await runStaticAnalysis({ rootDir: FIXTURE, targetPath: maliciousPath }); + } catch { + // errors are acceptable – injection must not succeed + } + + let injected = false; + try { + await readFile(SENTINEL, "utf-8"); + injected = true; + } catch { + injected = false; + } + assert.strictEqual(injected, false, "Command injection via semicolon succeeded – sentinel file was created"); + }); + + it("should not execute injected commands via pipe ending with .py", async () => { + const maliciousPath = `safe.py | tee ${SENTINEL} | cat foo.py`; + try { + await runStaticAnalysis({ rootDir: FIXTURE, targetPath: maliciousPath }); + } catch { + // errors are acceptable – injection must not succeed + } + + let injected = false; + try { + await readFile(SENTINEL, "utf-8"); + injected = true; + } catch { + injected = false; + } + assert.strictEqual(injected, false, "Command injection via pipe succeeded – sentinel file was created"); + }); +});