fix(scan-orchestrator): honor .llm-security-ignore only for the caller's own working tree
A foreign or freshly-cloned scan target could ship its own
.llm-security-ignore with `**` and silently turn a real finding into
ALLOW (0 findings) -- only the undocumented envelope.suppressed count
revealed it. Same defect class the v8.0.0 commons-root fix closed: a
hostile repo should never be able to empty its own detection output.
Valgt X fordi Y: honor the ignore file only when realpath(target)
equals or is nested under realpath(process.cwd()), and never when the
target resolves under os.tmpdir() -- defense-in-depth for the case a
caller's own cwd happens to sit under tmpdir, matching where
git-clone.mjs materializes clones. A foreign target with an ignore
file now gets one stderr line saying it was not honored (loud, not
silent).
Red test first (tests/scanners/ignore-file-scope.test.mjs): a known
HIGH entropy finding (random-bytes base64, built at test time) that
must survive on a foreign/cloned target and stay suppressed on the
caller's own working tree. Verified red on b6edfa3, green after the
fix. Self-scan invariant reverified on three independent fresh clones
today: WARNING 61/100, 58 findings, 382 suppressed -- identical on
patched and unpatched clones, so the own-tree path is unchanged.
Suite 2276/2270 pass/0 fail/6 skip (jetbrains-parser after-hook flake
listed, not counted -- known). Golden unchanged (109/7/4, 61/61).
av-surface 6/6 green, unchanged.
Open, not fixed this session (scope was the ignore file only):
policy-loader.mjs's loadPolicy() also reads .llm-security/policy.json
from the scanned target, independently re-read by entropy-scanner.mjs
and signature-scanner.mjs -- same defect class, not yet measured red.
Logged in PLAN.md S3b for a follow-up order.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
b6edfa3ceb
commit
0d37f5a628
2 changed files with 180 additions and 2 deletions
|
|
@ -4,8 +4,8 @@
|
||||||
// shares file discovery, outputs JSON envelope to stdout.
|
// shares file discovery, outputs JSON envelope to stdout.
|
||||||
// Zero external dependencies.
|
// Zero external dependencies.
|
||||||
|
|
||||||
import { resolve, join, dirname } from 'node:path';
|
import { resolve, join, dirname, sep } from 'node:path';
|
||||||
import { existsSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
|
import { existsSync, readFileSync, writeFileSync, appendFileSync, realpathSync } from 'node:fs';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { discoverFiles } from './lib/file-discovery.mjs';
|
import { discoverFiles } from './lib/file-discovery.mjs';
|
||||||
|
|
@ -22,10 +22,45 @@ const FAIL_ON_LEVELS = ['critical', 'high', 'medium', 'low'];
|
||||||
// SCANNER:glob — ignore findings from SCANNER matching file glob
|
// SCANNER:glob — ignore findings from SCANNER matching file glob
|
||||||
// glob — ignore findings from ALL scanners matching file glob
|
// glob — ignore findings from ALL scanners matching file glob
|
||||||
// Globs use minimatch-style: * matches within path segment, ** across segments.
|
// Globs use minimatch-style: * matches within path segment, ** across segments.
|
||||||
|
//
|
||||||
|
// S3b (v8.1.0, 2026-09-22): the ignore file is read from the SCANNED TARGET,
|
||||||
|
// so a foreign/cloned target could ship `.llm-security-ignore` with `**` and
|
||||||
|
// silently empty its own findings — a hostile repo overriding the scan of
|
||||||
|
// itself. Same defect class the v8.0.0 commons-root fix closed. The rule:
|
||||||
|
// honor the ignore file ONLY when the target is the caller's own working
|
||||||
|
// directory (or a subdirectory of it), and NEVER when the target resolves
|
||||||
|
// under the OS temp directory (where git-clone.mjs materializes clones) —
|
||||||
|
// the second check is defense-in-depth for the case a caller's own cwd sits
|
||||||
|
// under tmpdir. Loud, not silent: a foreign target with an ignore file logs
|
||||||
|
// one stderr line saying it was not honored.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
function isOwnWorkingTree(targetPath) {
|
||||||
|
let resolvedTarget;
|
||||||
|
let resolvedCwd;
|
||||||
|
let resolvedTmp;
|
||||||
|
try {
|
||||||
|
resolvedTarget = realpathSync(resolve(targetPath));
|
||||||
|
resolvedCwd = realpathSync(process.cwd());
|
||||||
|
resolvedTmp = realpathSync(tmpdir());
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (resolvedTarget === resolvedTmp || resolvedTarget.startsWith(resolvedTmp + sep)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return resolvedTarget === resolvedCwd || resolvedTarget.startsWith(resolvedCwd + sep);
|
||||||
|
}
|
||||||
|
|
||||||
function loadIgnoreRules(targetPath) {
|
function loadIgnoreRules(targetPath) {
|
||||||
const ignoreFile = join(targetPath, '.llm-security-ignore');
|
const ignoreFile = join(targetPath, '.llm-security-ignore');
|
||||||
if (!existsSync(ignoreFile)) return [];
|
if (!existsSync(ignoreFile)) return [];
|
||||||
|
if (!isOwnWorkingTree(targetPath)) {
|
||||||
|
console.error(
|
||||||
|
`[deep-scan] ${ignoreFile}: .llm-security-ignore was found but is ignored — ` +
|
||||||
|
`the scanned target is not the caller's own working directory (foreign/cloned target)\n`
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const lines = readFileSync(ignoreFile, 'utf8').split('\n');
|
const lines = readFileSync(ignoreFile, 'utf8').split('\n');
|
||||||
const rules = [];
|
const rules = [];
|
||||||
for (const raw of lines) {
|
for (const raw of lines) {
|
||||||
|
|
|
||||||
143
tests/scanners/ignore-file-scope.test.mjs
Normal file
143
tests/scanners/ignore-file-scope.test.mjs
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
// 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue