// 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();