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

@ -15,43 +15,25 @@
import { readFileSync } from 'node:fs';
import { normalize } from 'node:path';
import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs';
import { SECRET_PATTERNS as COMMONS_SECRET_PATTERNS } from '../../scanners/lib/secret-egress.mjs';
// ---------------------------------------------------------------------------
// Secret detection patterns (union of global, kiur, llm-security, ms-ai-architect)
// Secret detection patterns
// ---------------------------------------------------------------------------
//
// The 19 fixed entries (union of global, kiur, llm-security, ms-ai-architect)
// were regex literals here until the v8 Phase 5 swap; they now come from
// `signatures/secret-egress.json` in the vendored commons, via
// scanners/lib/secret-egress.mjs — measured position-by-position before the
// swap (order, name, source, flags), zero divergences. Order is load-bearing:
// first match wins, and the entry a finding is reported AS depends on it.
// See that module's header for what happens when commons is unresolvable.
//
// Policy-defined patterns are appended after, unchanged: they are the scanned
// project's own policy, not commons data, and must never displace the fixed
// table's labels.
const SECRET_PATTERNS = [
{ name: 'AWS Access Key ID', pattern: /AKIA[0-9A-Z]{16}/ },
{ name: 'AWS Secret Access Key', pattern: /(?:aws_secret(?:_access)?_key|AWS_SECRET(?:_ACCESS)?_KEY)\s*[=:]\s*['"]?[0-9a-zA-Z/+=]{40}['"]?/i },
{ name: 'Azure Connection String (AccountKey/SharedAccessKey/sig)', pattern: /(?:AccountKey|SharedAccessKey|sig)=[A-Za-z0-9+/=]{20,}/ },
{ name: 'Azure AD ClientSecret', pattern: /(?:client[_-]?secret|ClientSecret)\s*[=:]\s*['"][^'"]{8,}['"]/i },
{ name: 'Azure AI Services Key', pattern: /Ocp-Apim-Subscription-Key\s*[=:]\s*['"]?[0-9a-f]{32}['"]?/i },
{ name: 'GitHub Token', pattern: /(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,}/ },
{ name: 'npm Token', pattern: /npm_[A-Za-z0-9]{36}/ },
// v7.8.3 #13 — bare provider keys (see knowledge/secrets-patterns.md).
// Previously these were caught only when wrapped in a quoted label
// assignment (password|secret|token|api_key = "..."); the bare key forms
// slipped through.
{ name: 'Anthropic API Key', pattern: /\bsk-ant-api03-[A-Za-z0-9_-]{93}\b/ },
{ name: 'OpenAI Project Key', pattern: /\bsk-proj-[A-Za-z0-9_-]{40,}\b/ },
{ name: 'GitHub Fine-Grained PAT', pattern: /\bgithub_pat_[A-Za-z0-9_]{82}\b/ },
{ name: 'Google API Key', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/ },
{ name: 'Private Key PEM Block', pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/ },
{ name: 'JWT Secret', pattern: /JWT[_-]?SECRET\s*[=:]\s*['"][^'"]{8,}['"]/i },
{ name: 'Slack/Discord Webhook URL', pattern: /https:\/\/(?:hooks\.slack\.com\/services|discord(?:app)?\.com\/api\/webhooks)\// },
{ name: 'Generic credential assignment', pattern: /(?:password|passwd|secret|token|api[_-]?key)\s*[=:]\s*['"][^'"]{8,}['"]/i },
{ name: 'Authorization header with token', pattern: /[Bb]earer [A-Za-z0-9\-._~+/]{20,}/ },
{ name: 'Database connection string', pattern: /(?:postgres|mysql|mongodb|redis):\/\/[^\s]+@[^\s]+/i },
// OpenAI legacy API key (pre-2024 sk-<48 chars> shape). Anchored on the
// T3BlbkFJ base64 "OpenAI" watermark embedded mid-token rather than a
// bare sk-+48alnum shape, which would collide with sk-ant-/sk-proj- and
// other unrelated sk-* tokens. Recall gap: bare/unquoted legacy keys
// (no label assignment, no Bearer prefix) previously slipped through.
{ name: 'OpenAI Legacy API Key', pattern: /\bsk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}\b/ },
// v7.8.3 #13 — three-part JWT (header.payload.signature, base64url). The
// 10-char part minimum keeps prose fragments (eyJabc.def.ghi) from tripping.
// Kept last so a Bearer-header context reports as 'Authorization header'.
{ name: 'JWT (three-part token)', pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ },
// Policy-defined additional patterns
...COMMONS_SECRET_PATTERNS,
...getPolicyValue('secrets', 'additional_patterns', []).map((p, i) => ({
name: `Custom pattern ${i + 1}`,
pattern: new RegExp(p),