// auto-cleaner-rce.test.mjs — Security regression for the v7.8.0 CRITICAL (RCE). // // auto-cleaner's validateContent() syntax-checked .mjs/.js/.cjs candidates by shelling // out: execSync(`node --check "${tmpPath}"`). `tmpPath` derives from the finding's // `file` field — an untrusted scanned-repo FILENAME. The F-2 guard checks path // containment (the path must stay inside the target tree) but never strips or quotes // shell metacharacters, and a filename containing `"` closes the interpolated quote. // // A file named `x";;".mjs` inside an untrusted repo therefore turned // `/security clean` (live mode is the documented default) into arbitrary command // execution on the operator's machine. // // This test drops such a file in the scanned tree and asserts the injected command // never ran. It FAILS while the sink goes through a shell, and PASSES once the call // is a no-shell spawnSync array. import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { resetCounter } from '../../scanners/lib/output.mjs'; import { applyFixes, validateContent } from '../../scanners/auto-cleaner.mjs'; const ZW = '​'; // zero-width space — strip_zero_width will remove it // The payload target goes through $CLEANER_RCE_CANARY because a literal path would // need `/`, which no filename may contain — the env-var expansion also proves a // *shell* interpreted the string rather than exec'ing an argv array. const HOSTILE_NAME = 'x";touch $CLEANER_RCE_CANARY;".mjs'; describe('auto-cleaner command-injection regression (CRITICAL, v7.8.1)', () => { // Layer 1 — the sink itself. Exercised directly, because the applyFixes-level // metachar guard (layer 2) would otherwise stop the input before it ever reaches // validateContent, and the test would pass without proving the shell is gone. it('validateContent does not shell-interpret a hostile filename', () => { const root = mkdtempSync(join(tmpdir(), 'auto-cleaner-rce-sink-')); const canary = join(root, 'pwned'); try { process.env.CLEANER_RCE_CANARY = canary; const result = validateContent(join(root, HOSTILE_NAME), 'export const a = 1;\n'); assert.equal( existsSync(canary), false, 'COMMAND INJECTION: validateContent shell-executed a command embedded in the filename', ); assert.equal(result.valid, true, 'syntactically valid content must still validate'); } finally { delete process.env.CLEANER_RCE_CANARY; rmSync(root, { recursive: true, force: true }); } }); // Layer 2 — the belt: such a finding never reaches any sink in the first place. it('applyFixes refuses a finding whose filename carries shell metacharacters', async () => { const root = mkdtempSync(join(tmpdir(), 'auto-cleaner-rce-')); const target = join(root, 'scan-target'); const canary = join(root, 'pwned'); try { mkdirSync(target, { recursive: true }); process.env.CLEANER_RCE_CANARY = canary; // Zero-width char makes the UNI finding auto-tier AND makes the content actually // change, so without the guard this file would flow on into the fix pipeline. writeFileSync( join(target, HOSTILE_NAME), `export const a = 1;${ZW}\n`, 'utf-8', ); const findings = [{ id: 'RCE-1', scanner: 'UNI', title: 'Zero-width characters detected', severity: 'high', file: HOSTILE_NAME, }]; resetCounter(); const { fixes } = await applyFixes(target, findings, /* dryRun */ false); assert.equal( existsSync(canary), false, 'COMMAND INJECTION: a shell command embedded in a scanned filename was executed by auto-cleaner', ); // ...and it must be surfaced as refused, never silently dropped. const skipped = fixes.find((x) => x.finding_id === 'RCE-1'); assert.equal(skipped?.status, 'skipped'); assert.match(skipped.description, /metacharacters/); } finally { delete process.env.CLEANER_RCE_CANARY; rmSync(root, { recursive: true, force: true }); rmSync(canary, { force: true }); } }); });