// ignore-file-scope.test.mjs — .llm-security-ignore must never be honored // for a scanned target that is not the user's own working tree (S3b, // v8.1.0, 2026-09-22). scan-orchestrator.mjs reads .llm-security-ignore // straight from the SCANNED TARGET; a foreign/cloned target with `**` in // its own ignore file silently turned a real finding into ALLOW (only the // undocumented envelope.suppressed count revealed it). Same defect class as // the v8.0.0 commons-root fix: a hostile repo must not be able to empty its // own detection output by shipping an ignore file. // // Two scenarios, one real known finding (a random-bytes base64 string in a // .js file, which the entropy scanner classifies HIGH — verified directly // against entropy-scanner.mjs before this test was written): // OWN working tree (spawn cwd === target, target arg '.', mirrors the real // `node scanners/scan-orchestrator.mjs .` self-scan invocation): the // `**` ignore rule MUST still suppress the finding — verdict ALLOW, // envelope.suppressed === 1. This is the invariant S3b must not break. // Deliberately placed OUTSIDE os.tmpdir() AND outside this repo's git // working tree (a throwaway dir under $HOME, removed in `after()`): the // fix excludes every path under the OS temp dir regardless of cwd, and a // fixture nested inside this repo's own tree lets the git/permission/etc // scanners pick up THIS repo's real history — either would fail this // scenario for the wrong reason. // FOREIGN target (spawn cwd elsewhere, target = absolute path under // os.tmpdir(), i.e. what git-clone.mjs produces): the `**` ignore rule // MUST NOT be honored — the HIGH finding must survive, verdict WARNING, // envelope.suppressed falsy. import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { resolve, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawn } from 'node:child_process'; import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir, homedir } from 'node:os'; import crypto from 'node:crypto'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ORCHESTRATOR = resolve(__dirname, '../../scanners/scan-orchestrator.mjs'); const REPO_ROOT = resolve(__dirname, '../..'); // 72 random bytes -> base64 gives length 96, well past the entropy scanner's // HIGH threshold (H>=5.2, len>=40) regardless of exact byte values. Not a // real credential — a known-positive blob for the entropy scanner only. const HIGH_ENTROPY_BLOB = crypto.randomBytes(72).toString('base64'); const IGNORE_ALL = '**\n'; function writeFixture(dir) { writeFileSync(join(dir, 'config.js'), `const payload = "${HIGH_ENTROPY_BLOB}";\nmodule.exports = { payload };\n`); writeFileSync(join(dir, '.llm-security-ignore'), IGNORE_ALL); } function runOrchestrator(target, cwd) { return new Promise((resolveP) => { const stdout = []; const stderr = []; const child = spawn('node', [ORCHESTRATOR, target], { cwd, timeout: 180_000, stdio: ['ignore', 'pipe', 'pipe'], }); child.stdout.on('data', (c) => stdout.push(c)); child.stderr.on('data', (c) => stderr.push(c)); child.on('close', (code) => { resolveP({ code: code ?? 1, stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'), }); }); }); } function findEntropyFindings(envelope) { return envelope?.scanners?.entropy?.findings || []; } describe('.llm-security-ignore is scoped to the user\'s own working tree (S3b)', () => { describe('OWN working tree: `node scanners/scan-orchestrator.mjs .` self-scan shape', () => { let ownDir; let result; let env; before(async () => { // Outside os.tmpdir() AND outside this repo's git tree — see the // file-header note. ownDir = mkdtempSync(join(homedir(), '.ignore-scope-own-')); writeFixture(ownDir); // Mirrors the real self-scan invocation: cwd IS the target, arg is '.'. result = await runOrchestrator('.', ownDir); env = JSON.parse(result.stdout); }); after(() => { rmSync(ownDir, { recursive: true, force: true }); }); it('suppresses the known HIGH entropy finding via the `**` ignore rule', () => { assert.equal(findEntropyFindings(env).length, 0, 'entropy finding must be suppressed when the ignore file belongs to the scanned working tree'); }); it('reports the suppression count (loud, not silent)', () => { assert.equal(env.suppressed, 1, 'envelope.suppressed must reflect the one suppressed finding'); }); it('verdict is ALLOW', () => { assert.equal(env.aggregate.verdict, 'ALLOW'); }); }); describe('FOREIGN target: absolute path under os.tmpdir(), scanned from a different cwd', () => { let foreignDir; let result; let env; before(async () => { foreignDir = mkdtempSync(join(tmpdir(), 'ignore-scope-foreign-')); writeFixture(foreignDir); // Mirrors a remote-clone scan: cwd is the repo, target is a foreign // absolute path — exactly what git-clone.mjs hands the orchestrator. result = await runOrchestrator(foreignDir, REPO_ROOT); env = JSON.parse(result.stdout); }); after(() => { rmSync(foreignDir, { recursive: true, force: true }); }); it('does NOT suppress the known HIGH entropy finding — the target\'s own ignore file is not honored', () => { assert.equal(findEntropyFindings(env).length, 1, 'entropy finding must survive when the ignore file belongs to a foreign/cloned target, not the caller\'s own working tree'); }); it('reports no suppression', () => { assert.ok(!env.suppressed, `envelope.suppressed must be falsy for a foreign target, got ${env.suppressed}`); }); it('verdict is WARNING (the HIGH finding stands)', () => { assert.equal(env.aggregate.verdict, 'WARNING'); }); it('logs a stderr line stating the ignore file was not honored, and why', () => { assert.match(result.stderr, /\.llm-security-ignore.*ignored/i, 'a foreign-target ignore-file override must be loud, not silent'); }); }); });