The 19 fixed credential shapes in pre-edit-secrets.mjs were regex literals; they now come from signatures/secret-egress.json in the vendored commons via a new scanners/lib/secret-egress.mjs. Policy-injected custom patterns (entries 20+) are unchanged and still appended by the hook. Measured before the swap, not assumed: all 19 positions compared for order, name, regex source and flags, plus recompilation identity, against the literal table sliced out of the module text. Zero divergences. Commons had reported the same result; that was their measurement, so this one was run anyway. STATE's expectation that the golden gate would go red on both table records and file sha256 was wrong: pre-edit-secrets.mjs is in neither PINNED_FILES nor WALKED_MODULES, so the table had no golden coverage at all and the swap moved nothing. Rather than leave the vendored data with only behavioural coverage, secret-egress.mjs joins WALKED_MODULES — walked, not pinned, since it inlines no regex of its own. Golden diff was 19 ADDED, 0 CHANGED, 0 REMOVED, each source byte-identical to the pre-swap literal; re-blessed. suite-counts.json untouched. Tests: coverage is derived from the loaded table, so an entry commons adds cannot arrive without an end-to-end probe. All 19 now block through the real hook and are asserted by label, which also pins the ordering contract (a Bearer-wrapped JWT must report as the header). Mutating the vendored JSON fires in both directions plus reorder: under-match (AKIA quantifier) reddens 3 hook tests + golden; over-match (Anthropic key truncated to its prefix) reddens the false-positive probe + golden; moving the JWT entry ahead of the Bearer entry reddens the ordering test. Suite 2231 tests / 2223 pass / 6 skipped. The two parallel-run failures (pre-compact size-cap, benchmark) pass alone — the known timing flakes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MGMv5ZTUhVzZtCCwRrNZG5
409 lines
15 KiB
JavaScript
409 lines
15 KiB
JavaScript
// golden-dump.mjs — builds the v8 Phase 5 golden baseline artifacts.
|
|
//
|
|
// Two builders, both pure and deterministic, both used by BOTH the generator
|
|
// (`scripts/golden-baseline.mjs --write`) and the gate
|
|
// (`tests/lib/golden-baseline.test.mjs`). One code path, so the gate cannot
|
|
// pass against a stale generator.
|
|
//
|
|
// Determinism rules observed here:
|
|
// - no timestamps, no absolute paths, no host details in the output;
|
|
// - every collection is emitted in a deterministic order — export-walk order
|
|
// comes from sorted key names, not from V8 enumeration luck;
|
|
// - the reference run executes hooks SEQUENTIALLY. Array order is semantic
|
|
// in this codebase (dedup + output order) and the plan forbids
|
|
// key-sorting tooling, so the fix for concurrency-dependent ordering is to
|
|
// remove the concurrency, not to sort the result.
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import { execFile, execFileSync } from 'node:child_process';
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve, relative } from 'node:path';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Canonical serialization for every artifact: 2-space JSON + trailing NL. */
|
|
export function serialize(obj) {
|
|
return JSON.stringify(obj, null, 2) + '\n';
|
|
}
|
|
|
|
function sha256(s) {
|
|
return createHash('sha256').update(s, 'utf8').digest('hex');
|
|
}
|
|
|
|
/** Modules whose exports are walked for regex + table records. */
|
|
const WALKED_MODULES = [
|
|
['injection-patterns', 'scanners/lib/injection-patterns.mjs'],
|
|
['string-utils', 'scanners/lib/string-utils.mjs'],
|
|
['severity', 'scanners/lib/severity.mjs'],
|
|
// Swapped to vendored commons in v8 Phase 5. Walked, not pinned: the module
|
|
// compiles its table from JSON and inlines no regex of its own, so the
|
|
// export walk sees every pattern the hook will run. Without this entry the
|
|
// 19 credential shapes had no digest anchor at all — the hook is not a
|
|
// walked module, so a change to the vendored JSON moved nothing here.
|
|
['secret-egress', 'scanners/lib/secret-egress.mjs'],
|
|
];
|
|
|
|
/**
|
|
* Source files pinned by digest. This is the completeness layer: it covers
|
|
* every regex inlined in a function body that no export walk can reach.
|
|
*/
|
|
const PINNED_FILES = [
|
|
'scanners/lib/injection-patterns.mjs',
|
|
'scanners/lib/string-utils.mjs',
|
|
'scanners/lib/severity.mjs',
|
|
'knowledge/signatures.json',
|
|
'knowledge/attack-mutations.json',
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Layer 1+2: export walk -> regex records and table records
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Depth-first walk of an exported value, collecting RegExp instances with a
|
|
* stable path key (`CRITICAL_PATTERNS[3].pattern`).
|
|
*
|
|
* Reads `.source`/`.flags` off the compiled object. This is the whole point:
|
|
* after a Phase 5 swap the pattern is `new RegExp(jsonString, flags)`, and a
|
|
* backslash lost to JSON escaping is visible ONLY in the compiled source.
|
|
*/
|
|
function collectRegexes(value, path, out, seen) {
|
|
if (value instanceof RegExp) {
|
|
out.push({ kind: 'regex', key: path, source: value.source, flags: value.flags });
|
|
return;
|
|
}
|
|
if (value === null || typeof value !== 'object') return;
|
|
if (seen.has(value)) return;
|
|
seen.add(value);
|
|
|
|
if (Array.isArray(value)) {
|
|
value.forEach((v, i) => collectRegexes(v, `${path}[${i}]`, out, seen));
|
|
return;
|
|
}
|
|
for (const k of Object.keys(value).sort()) {
|
|
collectRegexes(value[k], `${path}.${k}`, out, seen);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A data table is an exported plain object or array of scalars — the shape
|
|
* Phase 4 moves into commons as JSON. Arrays of pattern objects are NOT
|
|
* tables; their regexes are already pinned individually above, and digesting
|
|
* them again would double-count without adding signal.
|
|
*/
|
|
function isDataTable(value) {
|
|
if (value === null || typeof value !== 'object') return false;
|
|
if (value instanceof RegExp) return false;
|
|
const values = Array.isArray(value) ? value : Object.values(value);
|
|
if (values.length === 0) return false;
|
|
// A value is either a scalar (HOMOGLYPH_MAP: char -> char, SEVERITY: name ->
|
|
// name) or an array of scalars (the four OWASP maps: prefix -> ['LLM01']).
|
|
const scalar = (v) => typeof v === 'string' || typeof v === 'number';
|
|
return values.every((v) => scalar(v) || (Array.isArray(v) && v.every(scalar)));
|
|
}
|
|
|
|
/**
|
|
* Stable digest of a table's contents. Keys are sorted for the digest input
|
|
* only — the artifact never reorders anything the runtime reads.
|
|
*/
|
|
function digestTable(value) {
|
|
const canonical = Array.isArray(value)
|
|
? JSON.stringify(value)
|
|
: JSON.stringify(
|
|
Object.keys(value)
|
|
.sort()
|
|
.map((k) => [k, value[k]])
|
|
);
|
|
return {
|
|
entries: Array.isArray(value) ? value.length : Object.keys(value).length,
|
|
digest: `sha256:${sha256(canonical)}`,
|
|
};
|
|
}
|
|
|
|
export async function buildPatternDump(root) {
|
|
const records = [];
|
|
|
|
for (const [alias, relPath] of WALKED_MODULES) {
|
|
const mod = await import(resolve(root, relPath));
|
|
for (const exportName of Object.keys(mod).sort()) {
|
|
const value = mod[exportName];
|
|
if (typeof value === 'function') continue;
|
|
|
|
collectRegexes(value, `${alias}:${exportName}`, records, new WeakSet());
|
|
|
|
if (isDataTable(value)) {
|
|
records.push({ kind: 'table', key: `${alias}:${exportName}`, ...digestTable(value) });
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const relPath of PINNED_FILES) {
|
|
records.push({
|
|
kind: 'file',
|
|
key: relPath,
|
|
sha256: sha256(readFileSync(resolve(root, relPath), 'utf8')),
|
|
});
|
|
}
|
|
|
|
// Stable ordering across kinds so a re-run of the walk cannot reshuffle the
|
|
// artifact for reasons unrelated to the code under test.
|
|
const kindRank = { regex: 0, table: 1, file: 2 };
|
|
records.sort(
|
|
(a, b) => kindRank[a.kind] - kindRank[b.kind] || a.key.localeCompare(b.key)
|
|
);
|
|
|
|
return {
|
|
artifact: 'golden-pattern-dump',
|
|
schema: 1,
|
|
counts: {
|
|
regex: records.filter((r) => r.kind === 'regex').length,
|
|
table: records.filter((r) => r.kind === 'table').length,
|
|
file: records.filter((r) => r.kind === 'file').length,
|
|
},
|
|
records,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Layer 4: reference run — 61 showcase payloads through real hook entry points
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const HOOK_SCRIPTS = {
|
|
'pre-prompt-inject-scan': 'hooks/scripts/pre-prompt-inject-scan.mjs',
|
|
'post-mcp-verify': 'hooks/scripts/post-mcp-verify.mjs',
|
|
'pre-bash-destructive': 'hooks/scripts/pre-bash-destructive.mjs',
|
|
};
|
|
|
|
/** Mirrors the stdin protocol in examples/prompt-injection-showcase/run-showcase.mjs. */
|
|
function buildInput(payload) {
|
|
switch (payload.hook) {
|
|
case 'pre-prompt-inject-scan':
|
|
return { session_id: 'golden', message: { role: 'user', content: payload.payload } };
|
|
case 'post-mcp-verify':
|
|
return {
|
|
tool_name: payload.inputTool || 'mcp__server__tool',
|
|
tool_input: {},
|
|
tool_output: payload.payload,
|
|
};
|
|
case 'pre-bash-destructive':
|
|
return { tool_name: 'Bash', tool_input: { command: payload.payload } };
|
|
default:
|
|
throw new Error(`Unknown hook: ${payload.hook}`);
|
|
}
|
|
}
|
|
|
|
function runHook(scriptPath, input, cwd) {
|
|
return new Promise((res) => {
|
|
const child = execFile(
|
|
process.execPath,
|
|
[scriptPath],
|
|
{ timeout: 15000, cwd, env: { ...process.env, LLM_SECURITY_UPDATE_CHECK: 'off' } },
|
|
(_err, stdout, stderr) => {
|
|
res({ code: child.exitCode ?? 1, stdout: stdout || '', stderr: stderr || '' });
|
|
}
|
|
);
|
|
child.stdin.end(JSON.stringify(input));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Exit code + stdout -> verdict. Mirrors `classify()` in
|
|
* examples/prompt-injection-showcase/run-showcase.mjs, including the
|
|
* `advisory` state (exit 0 WITH output): a hook that warns is not a hook that
|
|
* allowed, and collapsing the two would understate detection across the whole
|
|
* MEDIUM tier of the corpus.
|
|
*/
|
|
function classify(code, stdout) {
|
|
if (code === 2) return 'block';
|
|
if (code === 0) return stdout.trim() ? 'advisory' : 'allow';
|
|
return `error(${code})`;
|
|
}
|
|
|
|
/**
|
|
* Strips everything host- or run-specific from hook output so the artifact is
|
|
* byte-stable: absolute paths, PIDs, durations, and the session file path.
|
|
*/
|
|
function normalizeOutput(s, root) {
|
|
return s
|
|
.split(root)
|
|
.join('<ROOT>')
|
|
.replace(/llm-security-session-\d+\.jsonl/g, 'llm-security-session-<PID>.jsonl')
|
|
.replace(/\b\d+(\.\d+)?\s?ms\b/g, '<MS>')
|
|
.replace(/\b\d{4}-\d{2}-\d{2}T[\d:.]+Z\b/g, '<TS>')
|
|
.trimEnd();
|
|
}
|
|
|
|
export async function buildReferenceRun(root) {
|
|
const payloads = JSON.parse(
|
|
readFileSync(resolve(root, 'examples/prompt-injection-showcase/payloads.json'), 'utf8')
|
|
);
|
|
|
|
const cases = [];
|
|
// Sequential on purpose — see the determinism note at the top of this file.
|
|
for (const p of payloads) {
|
|
const { code, stdout, stderr } = await runHook(
|
|
resolve(root, HOOK_SCRIPTS[p.hook]),
|
|
buildInput(p),
|
|
root
|
|
);
|
|
const verdict = classify(code, stdout);
|
|
cases.push({
|
|
id: p.id,
|
|
hook: p.hook,
|
|
category: p.category,
|
|
expected: p.expected,
|
|
exitCode: code,
|
|
verdict,
|
|
matchesExpectation: verdict === p.expected,
|
|
stdout: normalizeOutput(stdout, root),
|
|
stderr: normalizeOutput(stderr, root),
|
|
});
|
|
}
|
|
|
|
return {
|
|
artifact: 'golden-reference-run',
|
|
schema: 1,
|
|
coverage: await buildCoverage(root, payloads),
|
|
summary: {
|
|
total: cases.length,
|
|
matchingExpectation: cases.filter((c) => c.matchesExpectation).length,
|
|
},
|
|
cases,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* STATIC REACHABILITY — not observed coverage. Read the distinction before
|
|
* quoting the number anywhere.
|
|
*
|
|
* This probes every injection pattern against every payload string
|
|
* in-process. It answers "if you threw all 61 payload strings at all 83
|
|
* regexes, how many would match?" It does NOT answer "how many patterns did
|
|
* the 61 hook invocations actually evaluate": the `pre-bash-destructive`
|
|
* payloads never reach injection-patterns at all, yet their strings are in
|
|
* the probe set and can mark a pattern reachable.
|
|
*
|
|
* It is kept because it still bounds the gate honestly — an unreachable
|
|
* pattern is one the corpus cannot protect under ANY attribution — but it is
|
|
* named for what it measures. A number labelled as measuring one thing while
|
|
* measuring another is this repo's v7.8.2 defect class.
|
|
*
|
|
* Not covered here at all: the four OWASP maps are scanner-side and no hook
|
|
* in this corpus reaches them. Their only protection is the table digest.
|
|
*/
|
|
async function buildCoverage(root, payloads) {
|
|
const ip = await import(resolve(root, 'scanners/lib/injection-patterns.mjs'));
|
|
const su = await import(resolve(root, 'scanners/lib/string-utils.mjs'));
|
|
|
|
const groups = ['CRITICAL_PATTERNS', 'HIGH_PATTERNS', 'MEDIUM_PATTERNS', 'HYBRID_PATTERNS'];
|
|
const entries = [];
|
|
for (const g of groups) {
|
|
(ip[g] || []).forEach((entry, i) => {
|
|
if (entry && entry.pattern instanceof RegExp) entries.push({ key: `${g}[${i}]`, re: entry.pattern });
|
|
});
|
|
}
|
|
|
|
const texts = payloads.flatMap((p) => [p.payload, su.normalizeForScan(p.payload)]);
|
|
const reachable = [];
|
|
const unreachable = [];
|
|
for (const { key, re } of entries) {
|
|
const probe = new RegExp(re.source, re.flags.replace('g', ''));
|
|
(texts.some((t) => probe.test(t)) ? reachable : unreachable).push(key);
|
|
}
|
|
|
|
return {
|
|
kind: 'static-reachability',
|
|
note:
|
|
'Payload strings probed against patterns in-process. NOT a measurement ' +
|
|
'of what the 61 hook invocations evaluated — pre-bash-destructive ' +
|
|
'payloads never reach injection-patterns yet are in the probe set. ' +
|
|
'The four OWASP maps are scanner-side and have no behavioural coverage ' +
|
|
'here at all; their only protection is the table digest.',
|
|
patternsTotal: entries.length,
|
|
patternsReachable: reachable.length,
|
|
unreachablePatterns: unreachable,
|
|
corpusContains: {
|
|
// "a payload contains a homoglyph", not "the run folded one".
|
|
homoglyphChars: texts.some((t) => su.foldHomoglyphs(t) !== t),
|
|
payloadsAlteredByNormalize: payloads.filter(
|
|
(p) => su.normalizeForScan(p.payload) !== p.payload
|
|
).length,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Artifact (a): per-file suite counts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Per-file pass/fail counts, collected by running each test file in its OWN
|
|
* process, sequentially.
|
|
*
|
|
* Two reasons it is per-file rather than one total:
|
|
*
|
|
* 1. A total is unattributable. `2045` -> `2044` tells you something broke;
|
|
* it does not tell you where, and during a table-by-table swap that is
|
|
* most of the information.
|
|
* 2. `npm test` runs files concurrently, and three timing-sensitive files
|
|
* in this suite (pre-compact-scan size cap, attack-simulator
|
|
* --benchmark, pre-install-supply-chain F-3) flake under that load while
|
|
* passing in isolation. Running each file alone is exactly the
|
|
* already-documented procedure for trusting a red result, so the
|
|
* baseline is recorded the only way it is reproducible.
|
|
*
|
|
* Slow by construction (one node process per file) — hence opt-in via
|
|
* `--suite`, not part of the default regeneration.
|
|
*/
|
|
/**
|
|
* The gate's own file cannot be in its own floor: it READS
|
|
* `suite-counts.json`, which is produced by the run that would include it, so
|
|
* the first recording always captures it mid-bootstrap as a failure. Excluded
|
|
* by name and reported in the artifact rather than dropped silently — `npm
|
|
* test` still covers it.
|
|
*/
|
|
const SUITE_SELF_REFERENCE = 'tests/lib/golden-baseline.test.mjs';
|
|
|
|
export function buildSuiteCounts(root, files) {
|
|
const entries = [];
|
|
const excluded = [];
|
|
for (const file of files) {
|
|
const rel = relative(root, file);
|
|
if (rel === SUITE_SELF_REFERENCE) {
|
|
excluded.push({ file: rel, reason: 'self-reference: this file reads suite-counts.json' });
|
|
continue;
|
|
}
|
|
let out = '';
|
|
try {
|
|
out = execFileSync(process.execPath, ['--test', '--test-reporter=tap', rel], {
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
timeout: 300000,
|
|
env: { ...process.env, LLM_SECURITY_UPDATE_CHECK: 'off' },
|
|
});
|
|
} catch (err) {
|
|
// Non-zero exit is a failing file, not a harness error — its TAP
|
|
// summary is still on stdout and is what we want to record.
|
|
out = (err.stdout || '') + (err.stderr || '');
|
|
}
|
|
const pass = Number((out.match(/^# pass (\d+)$/m) || [])[1] ?? -1);
|
|
const fail = Number((out.match(/^# fail (\d+)$/m) || [])[1] ?? -1);
|
|
entries.push({ file: rel, pass, fail });
|
|
}
|
|
|
|
entries.sort((a, b) => a.file.localeCompare(b.file));
|
|
return {
|
|
artifact: 'golden-suite-counts',
|
|
schema: 1,
|
|
totals: {
|
|
files: entries.length,
|
|
pass: entries.reduce((n, e) => n + Math.max(e.pass, 0), 0),
|
|
fail: entries.reduce((n, e) => n + Math.max(e.fail, 0), 0),
|
|
},
|
|
excluded,
|
|
files: entries,
|
|
};
|
|
}
|