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
This commit is contained in:
Kjell Tore Guttormsen 2026-08-13 21:10:14 +02:00
commit c9652a6d3d
7 changed files with 484 additions and 34 deletions

View file

@ -0,0 +1,108 @@
// secret-egress.mjs — Secret/credential shapes, built from vendored commons.
//
// v8 Phase 5 step 4, fourth consumer swap. The 19 fixed entries were regex
// literals in hooks/scripts/pre-edit-secrets.mjs; they are now built once,
// here, from `signatures/secret-egress.json` in the vendored
// llm-security-commons subtree. Verified before the swap by a differential
// over all 19 positions — order, name, regex source and flags, plus
// recompilation identity (`new RegExp(json.pattern, json.flags).source` ===
// the literal's `.source`) — against the live hook table at commons v0.3.0:
// zero divergences, in order.
//
// Two properties are carried over from the injection-lexicon swap because
// they hold for the same reasons:
//
// 1. FAILURE IS LOUD. An empty table means the PreToolUse guard exits 0 for
// every Edit and Write — a credential gate that reports success without
// running, which is the v7.8.2 defect class. So an unresolvable commons
// writes one line to stderr. It still does not throw: a module-load
// throw inside a hook breaks the tool call rather than degrading it.
// The warning fires only for the DEFAULT root — an explicit
// `commonsRoot` is a test or a dev checkout pointing elsewhere on
// purpose, and warning there trains the reader to ignore the line.
//
// 2. ENTRIES ARE COMPILED DEFENSIVELY. commons is vendored data, not code:
// `new RegExp(source, flags)` throws on an uncompilable pattern or an
// unknown flag, and it would throw at module load, inside a hook. A
// malformed entry is dropped rather than published.
//
// Order is the contract, not a serialisation artefact: the artifact declares
// `first match wins; patterns are evaluated in ascending order`, and the
// entry a match is REPORTED as depends on it — a JWT inside an Authorization
// header must be labelled as the header, which only holds while
// 'JWT (three-part token)' stays last. The `order` field is honoured rather
// than trusting array position, which is what that field is in the artifact
// for.
//
// Scope: the fixed table only. Entries 20+ in the running hook are
// policy-injected custom patterns — consumer policy, not commons data — and
// are appended by the hook itself.
//
// Zero external dependencies — Node.js builtins only.
import { loadArtifact } from './commons-loader.mjs';
/**
* Compile one artifact entry into the `{ name, pattern }` shape the hook
* consumes, or return null if the entry cannot be trusted.
*/
function compileEntry(entry) {
if (entry === null || typeof entry !== 'object') return null;
if (typeof entry.pattern !== 'string' || typeof entry.name !== 'string') return null;
// Absent `flags` means no flags — the artifact's dialect note is explicit
// that flags are declared per pattern and never inlined as `(?i)`.
const flags = entry.flags ?? '';
if (typeof flags !== 'string') return null;
try {
return Object.freeze({ name: entry.name, pattern: new RegExp(entry.pattern, flags) });
} catch {
return null; // uncompilable source or unknown flag — drop, do not throw
}
}
/**
* Build the fixed secret-pattern table from a commons root.
*
* @param {object} [opts]
* @param {string} [opts.commonsRoot] - explicit commons root (tests, dev
* checkout). Suppresses the unresolvable-commons warning, which is meant
* for the default root only.
* @returns {ReadonlyArray<{name: string, pattern: RegExp}>} Always an array,
* so a lost commons yields an empty table rather than an undefined one the
* hook would iterate and throw on.
*/
export function buildSecretPatterns(opts = {}) {
const artifact = loadArtifact('signatures/secret-egress', {
fallback: null,
commonsRoot: opts.commonsRoot,
});
if (artifact === null && opts.commonsRoot === undefined) {
// See note 1 above: silent is the one thing this failure must not be.
process.stderr.write(
'[llm-security] secret-egress table unresolvable at scanners/commons/signatures/secret-egress.json — '
+ 'credential detection is DISABLED for this process. Reinstall the plugin or re-vendor the commons subtree.\n',
);
}
const entries = Array.isArray(artifact?.patterns) ? [...artifact.patterns] : [];
// Ascending `order` is the declared match semantics. Entries without a
// numeric order keep their array position relative to each other, which is
// the only reading left when the field is absent.
entries.sort((a, b) => {
const ao = typeof a?.order === 'number' ? a.order : Number.MAX_SAFE_INTEGER;
const bo = typeof b?.order === 'number' ? b.order : Number.MAX_SAFE_INTEGER;
return ao - bo;
});
const table = [];
for (const entry of entries) {
const compiled = compileEntry(entry);
if (compiled !== null) table.push(compiled);
}
return Object.freeze(table);
}
/** The fixed credential shapes a pre-write guard matches content against. */
export const SECRET_PATTERNS = buildSecretPatterns();