llm-security/tests/lib/secret-egress.test.mjs
Kjell Tore Guttormsen c9652a6d3d refactor(llm-security): build the secret table from vendored commons (secret-egress 0.3.0)
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
2026-08-13 21:10:14 +02:00

106 lines
5 KiB
JavaScript

// secret-egress.test.mjs — Tests for the commons-backed secret pattern table.
//
// v8 Phase 5 step 4, fourth consumer swap: the 19 fixed credential shapes stop
// being regex literals in hooks/scripts/pre-edit-secrets.mjs and are built
// from the vendored commons artifact `signatures/secret-egress.json` instead.
//
// What this file covers is what the other two layers cannot see:
//
// - The golden gate walks `secret-egress:SECRET_PATTERNS` and pins every
// pattern's source and flags, so byte-fidelity to the pre-swap literals is
// ITS job (measured post-for-post before the swap, zero divergences) and
// is not re-asserted here.
// - tests/hooks/pre-edit-secrets.test.mjs drives all 19 through the real
// hook, so BEHAVIOUR is its job.
// - Left over, and asserted here: that the table came from commons at all,
// that `order` is honoured rather than array position, and that a corrupt
// or missing commons degrades instead of throwing inside a hook.
//
// The loud half matters for the same reason it did for the injection lexicon:
// an empty table means the PreToolUse guard exits 0 for every Edit and Write —
// a credential gate reporting success without running.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { buildSecretPatterns, SECRET_PATTERNS } from '../../scanners/lib/secret-egress.mjs';
const MALFORMED_ROOT = new URL('../fixtures/commons-malformed-secret-egress/', import.meta.url).pathname;
describe('secret-egress (commons credential shapes)', () => {
describe('positive load through the real default commons root', () => {
it('publishes the table at its declared size', () => {
// A count that drifts means either commons changed the table or the
// vendored copy is partial; both must be looked at, not adjusted away.
assert.equal(
SECRET_PATTERNS.length, 19,
'SECRET_PATTERNS lost entries — is scanners/commons vendored?'
);
});
it('publishes compiled RegExp objects, not pattern strings', () => {
// The hook calls `pattern.test(content)` directly. A string would throw
// there, not here, and only once content reached that line.
for (const entry of SECRET_PATTERNS) {
assert.ok(entry.pattern instanceof RegExp, `${entry.name}: pattern is not a RegExp`);
assert.equal(typeof entry.name, 'string');
}
});
it('places the load-bearing last entry last', () => {
// The artifact declares `ordering.last_entry_is_load_bearing`: first
// match wins, so a JWT inside an Authorization header is reported as the
// header only while the bare-JWT shape stays behind it.
assert.equal(SECRET_PATTERNS.at(-1).name, 'JWT (three-part token)');
assert.equal(SECRET_PATTERNS[0].name, 'AWS Access Key ID');
assert.equal(SECRET_PATTERNS[15].name, 'Authorization header with token');
});
it('carries the flags the artifact declares, per pattern', () => {
// Six entries are case-insensitive and thirteen carry no flags. A
// builder that applied a blanket 'i' would pass a count check and
// silently widen thirteen shapes.
const insensitive = SECRET_PATTERNS.filter((p) => p.pattern.flags === 'i').map((p) => p.name);
assert.deepEqual(insensitive, [
'AWS Secret Access Key',
'Azure AD ClientSecret',
'Azure AI Services Key',
'JWT Secret',
'Generic credential assignment',
'Database connection string',
]);
assert.equal(SECRET_PATTERNS.filter((p) => p.pattern.flags === '').length, 13);
});
it('freezes the published table', () => {
assert.ok(Object.isFrozen(SECRET_PATTERNS));
assert.throws(() => { SECRET_PATTERNS.push({ name: 'x', pattern: /x/ }); }, TypeError);
});
});
describe('graceful degradation', () => {
it('yields an empty table when commons is unresolvable, without throwing', () => {
const table = buildSecretPatterns({ commonsRoot: '/nonexistent/commons-root' });
assert.deepEqual(table, []);
});
it('drops malformed entries instead of publishing them', () => {
// commons is vendored data, not code. An uncompilable pattern string
// would throw inside `new RegExp` at module load — in a hook, that is a
// broken tool call rather than a degraded scan.
const table = buildSecretPatterns({ commonsRoot: MALFORMED_ROOT });
assert.deepEqual(table.map((e) => e.name), [
'first by order, second in the array',
'second by order, first in the array',
]);
});
it('orders by the declared `order`, not by array position', () => {
// The artifact's match semantics are ascending `order`; the field exists
// precisely so a JSON round-trip cannot reorder the table silently.
const table = buildSecretPatterns({ commonsRoot: MALFORMED_ROOT });
assert.equal(table[0].pattern.source, 'fixture-shape-a');
assert.equal(table[0].pattern.flags, 'i');
assert.equal(table[1].pattern.source, 'fixture-shape-b');
});
});
});