fix(llm-security): CRITICAL — command injection in auto-cleaner (v7.8.1)

validateContent() syntax-checked .mjs/.js/.cjs candidates via
execSync(`node --check "${tmpPath}"`), where tmpPath derives from the
untrusted scanned-repo FILENAME. The F-2 guard checks path containment
but never quotes or strips shell metacharacters, so a file named
`x";<cmd>;".mjs` in a scanned repo turned `/security clean` (live is the
documented default) into arbitrary command execution. Verified with a
live PoC before the fix.

- validateContent: spawnSync('node', ['--check', tmpPath]) — no shell.
- CLI orchestrator fallback: same treatment (argv array, explicit
  status/error handling instead of execSync's throw-on-nonzero).
- Belt (defense-in-depth): applyFixes now refuses findings whose `file`
  carries shell or control metacharacters, reported as skipped.
- Regression tests cover both layers separately, so the belt cannot mask
  a re-introduced shell in the sink.

node --test: 1865/1865 green (1863 + 2 new).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
This commit is contained in:
Kjell Tore Guttormsen 2026-07-18 08:48:09 +02:00
commit f083cdad2e
2 changed files with 133 additions and 5 deletions

View file

@ -12,7 +12,7 @@ import { readFile, writeFile, rename, unlink, stat } from 'node:fs/promises';
import { writeFileSync, unlinkSync } from 'node:fs';
import { resolve, extname, join, dirname, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
import { spawnSync } from 'node:child_process';
import { fixResult, cleanEnvelope } from './lib/output.mjs';
// ---------------------------------------------------------------------------
@ -728,8 +728,15 @@ function validateContent(absPath, content) {
const tmpPath = absPath.replace(/(\.\w+)$/, '.clean-check$1');
try {
writeFileSync(tmpPath, content);
execSync(`node --check "${tmpPath}"`, { stdio: 'pipe', timeout: 5000 });
// No shell: `tmpPath` derives from an untrusted scanned-repo FILENAME, and a
// name like `x";cmd;".mjs` would break out of an interpolated command string
// (CRITICAL, fixed v7.8.1). spawnSync with an argv array never parses metachars.
const check = spawnSync('node', ['--check', tmpPath], { stdio: 'pipe', timeout: 5000 });
unlinkSync(tmpPath);
if (check.error) throw check.error;
if (check.status !== 0) {
throw new Error(String(check.stderr || '').trim() || `node --check exited ${check.status}`);
}
return { valid: true };
} catch (e) {
try { unlinkSync(tmpPath); } catch { /* ignore */ }
@ -773,6 +780,21 @@ async function applyFixes(targetPath, findings, dryRun) {
continue;
}
// Belt (v7.8.1): refuse untrusted filenames carrying shell metacharacters or
// control chars before they reach ANY sink. The command-injection fix above
// removed the shell from validateContent, so this is defense-in-depth — it keeps
// a future sink that does interpolate a path from becoming exploitable again.
if (/["'`$;|&<>\n\r\\]|[\x00-\x1f]/.test(f.file)) {
fixes.push(fixResult({
finding_id: f.id,
file: f.file,
operation: 'skip',
status: 'skipped',
description: 'Filename contains shell/control metacharacters — refused',
}));
continue;
}
const absPath = resolve(targetPath, f.file);
// F-2: path-traversal containment. `f.file` is untrusted (scanned-repo
@ -984,12 +1006,17 @@ async function main() {
console.error('[auto-cleaner] No --findings provided. Running scan-orchestrator...');
try {
const orchestratorPath = join(dirname(fileURLToPath(import.meta.url)), 'scan-orchestrator.mjs');
const result = execSync(`node "${resolve(orchestratorPath)}" "${targetPath}"`, {
// No shell — `targetPath` is operator-supplied but still never shell-parsed.
const run = spawnSync('node', [resolve(orchestratorPath), targetPath], {
encoding: 'utf-8',
timeout: 60000,
stdio: ['pipe', 'pipe', 'pipe'],
});
const envelope = JSON.parse(result);
if (run.error) throw run.error;
if (run.status !== 0) {
throw new Error(String(run.stderr || '').trim() || `scan-orchestrator exited ${run.status}`);
}
const envelope = JSON.parse(run.stdout);
findings = [];
for (const scanner of Object.values(envelope.scanners || {})) {
if (Array.isArray(scanner.findings)) {
@ -1052,4 +1079,4 @@ if (isMain) {
}
// Export for testing
export { classifyFinding, FIX_OPS, opsForFinding, applyFixes };
export { classifyFinding, FIX_OPS, opsForFinding, applyFixes, validateContent };