llm-security/tests/scanners/auto-cleaner-traversal.test.mjs
Kjell Tore Guttormsen 31aa2b4943
test(llm-security): build poisoned fixtures at test time, never on disk
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>
2026-09-22 13:07:38 +02:00

96 lines
4.1 KiB
JavaScript

// auto-cleaner-traversal.test.mjs — Security regression for F-2 (HIGH, arbitrary file write).
//
// auto-cleaner's applyFixes() historically did `resolve(targetPath, f.file)` with no
// containment check, then wrote the modified content back to that path. `f.file` comes
// from findings JSON — untrusted scanned-repo filenames, or a fully attacker-chosen
// `--findings` file. A finding with `file: "../secret.txt"` therefore let the cleaner
// modify (overwrite) a file OUTSIDE the scanned tree.
//
// This test places an auto-fixable file outside the target dir and points an auto-tier
// finding at it via "../secret.txt". It must FAIL while the sink is uncontained (the
// outside file gets rewritten) and PASS once applyFixes refuses paths that escape the
// target (prefix containment), reporting them as skipped instead of writing.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { applyFixes } from '../../scanners/auto-cleaner.mjs';
const ZW = String.fromCodePoint(0x200B); // zero-width space — strip_zero_width will remove it
describe('auto-cleaner path-traversal regression (F-2)', () => {
it('refuses to write a finding whose file path escapes the target directory', async () => {
// root/ <- mkdtemp parent
// secret.txt <- the escape target, OUTSIDE the scanned tree
// scan-target/ <- the directory actually passed to the cleaner
const root = mkdtempSync(join(tmpdir(), 'auto-cleaner-traversal-'));
const target = join(root, 'scan-target');
const escape = join(root, 'secret.txt');
try {
mkdirSync(target, { recursive: true });
// The escape file carries a zero-width char so the (auto-tier) strip_zero_width
// op WOULD change it — proving a write actually reaches outside the tree if
// containment is missing.
const original = `secret${ZW}value\n`;
writeFileSync(escape, original, 'utf-8');
// Untrusted finding: classified 'auto' (UNI zero-width), file traverses up and out.
const findings = [{
id: 'TRAVERSAL-1',
scanner: 'UNI',
title: 'Zero-width characters detected',
severity: 'high',
file: '../secret.txt',
}];
resetCounter();
const { fixes } = await applyFixes(target, findings, /* dryRun */ false);
// The file outside the target must be byte-for-byte untouched.
assert.equal(
readFileSync(escape, 'utf-8'),
original,
'PATH TRAVERSAL: auto-cleaner rewrote a file outside the scanned target directory',
);
// And the escaping finding must be surfaced as refused/skipped, never applied.
const escaped = fixes.find(f => f.file === '../secret.txt');
assert.ok(escaped, 'the escaping finding should be reported');
assert.notEqual(escaped.status, 'applied', 'escaping finding must not be applied');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('still applies fixes to files contained within the target directory', async () => {
const target = mkdtempSync(join(tmpdir(), 'auto-cleaner-contained-'));
try {
const inside = join(target, 'inside.md');
writeFileSync(inside, `---\ntitle: x${ZW}y\n---\nbody\n`, 'utf-8');
const findings = [{
id: 'CONTAINED-1',
scanner: 'UNI',
title: 'Zero-width characters detected',
severity: 'high',
file: 'inside.md',
}];
resetCounter();
const { fixes } = await applyFixes(target, findings, /* dryRun */ false);
assert.ok(
!readFileSync(inside, 'utf-8').includes(ZW),
'contained file should have been cleaned',
);
const applied = fixes.find(f => f.file === 'inside.md' && f.status === 'applied');
assert.ok(applied, 'a contained auto-fix should be applied');
} finally {
rmSync(target, { recursive: true, force: true });
}
});
});