v8.1.0 AV surface, session S1. The three poisoned fixture trees (signature-scan/poisoned, memory-scan/poisoned-project, trigger-scan/poisoned) are deleted from disk and materialized into a temp dir by the new tests/helpers/payload-trees.mjs. SIG-matching strings are assembled from fragments, the zero-width carrier comes from String.fromCodePoint, and every file carries the sha256 of the retired on-disk bytes; tests/helpers/payload-trees.test.mjs asserts the materialized trees are byte-identical (mutation-checked: one changed byte fails it). Inline payload literals in signature-scanner, signature-scanner-custom-rules and e2e/scan-pipeline are fragmented the same way; the literal U+200B in attack-simulator, auto-cleaner-rce and auto-cleaner-traversal is replaced by String.fromCodePoint(0x200B). av-surface: a 3->0, a2 3->0, c 5->1, d 5->2, b 9->8 (webshell-b64 blob gone). What remains (c=1, d=2, b) is under examples/** or is (b), both S2. Suite 2261 / 2252 pass / 3 fail (av-surface b, c, d only) / 6 skip. Golden output identical before/after (109/7/4, 61/61). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
101 lines
4.3 KiB
JavaScript
101 lines
4.3 KiB
JavaScript
// 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";<cmd>;".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 = String.fromCodePoint(0x200B); // 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 });
|
|
}
|
|
});
|
|
});
|