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:
parent
30dba2a457
commit
c9652a6d3d
7 changed files with 484 additions and 34 deletions
|
|
@ -15,43 +15,25 @@
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { normalize } from 'node:path';
|
import { normalize } from 'node:path';
|
||||||
import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs';
|
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 = [
|
const SECRET_PATTERNS = [
|
||||||
{ name: 'AWS Access Key ID', pattern: /AKIA[0-9A-Z]{16}/ },
|
...COMMONS_SECRET_PATTERNS,
|
||||||
{ 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
|
|
||||||
...getPolicyValue('secrets', 'additional_patterns', []).map((p, i) => ({
|
...getPolicyValue('secrets', 'additional_patterns', []).map((p, i) => ({
|
||||||
name: `Custom pattern ${i + 1}`,
|
name: `Custom pattern ${i + 1}`,
|
||||||
pattern: new RegExp(p),
|
pattern: new RegExp(p),
|
||||||
|
|
|
||||||
108
scanners/lib/secret-egress.mjs
Normal file
108
scanners/lib/secret-egress.mjs
Normal 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();
|
||||||
|
|
@ -37,6 +37,12 @@ const WALKED_MODULES = [
|
||||||
['injection-patterns', 'scanners/lib/injection-patterns.mjs'],
|
['injection-patterns', 'scanners/lib/injection-patterns.mjs'],
|
||||||
['string-utils', 'scanners/lib/string-utils.mjs'],
|
['string-utils', 'scanners/lib/string-utils.mjs'],
|
||||||
['severity', 'scanners/lib/severity.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'],
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
44
tests/fixtures/commons-malformed-secret-egress/signatures/secret-egress.json
vendored
Normal file
44
tests/fixtures/commons-malformed-secret-egress/signatures/secret-egress.json
vendored
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
{
|
||||||
|
"$comment": "Fixture for secret-egress.test.mjs graceful-degradation cases. Not a commons artifact: a hand-written table whose entries are individually broken in the ways vendored data can plausibly be broken, plus one entry placed out of array order so that `order`-honouring can be told apart from array-position luck. A builder that survives this file survives a corrupt commons without throwing inside a hook.",
|
||||||
|
"version": "0.0.0-fixture",
|
||||||
|
"id": "fixture/malformed-secret-egress",
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"order": 1,
|
||||||
|
"name": "second by order, first in the array",
|
||||||
|
"pattern": "fixture-shape-b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 0,
|
||||||
|
"name": "first by order, second in the array",
|
||||||
|
"pattern": "fixture-shape-a",
|
||||||
|
"flags": "i"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 2,
|
||||||
|
"name": "uncompilable: unbalanced group",
|
||||||
|
"pattern": "(?:unclosed["
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 3,
|
||||||
|
"name": "uncompilable: flag that is not a RegExp flag",
|
||||||
|
"pattern": "harmless",
|
||||||
|
"flags": "Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 4,
|
||||||
|
"name": "malformed: pattern is an object",
|
||||||
|
"pattern": { "source": "nope" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 5,
|
||||||
|
"pattern": "nameless"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 6,
|
||||||
|
"name": "malformed: flags is not a string",
|
||||||
|
"pattern": "harmless",
|
||||||
|
"flags": ["i"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"artifact": "golden-pattern-dump",
|
"artifact": "golden-pattern-dump",
|
||||||
"schema": 1,
|
"schema": 1,
|
||||||
"counts": {
|
"counts": {
|
||||||
"regex": 83,
|
"regex": 102,
|
||||||
"table": 7,
|
"table": 7,
|
||||||
"file": 5
|
"file": 5
|
||||||
},
|
},
|
||||||
|
|
@ -505,6 +505,120 @@
|
||||||
"source": "ignor(?:ez?|er?)\\s+(?:les?\\s+)?instructions?\\s+pr[e\\u00e9]c[e\\u00e9]dentes?",
|
"source": "ignor(?:ez?|er?)\\s+(?:les?\\s+)?instructions?\\s+pr[e\\u00e9]c[e\\u00e9]dentes?",
|
||||||
"flags": "i"
|
"flags": "i"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "secret-egress:SECRET_PATTERNS[0].pattern",
|
||||||
|
"source": "AKIA[0-9A-Z]{16}",
|
||||||
|
"flags": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "secret-egress:SECRET_PATTERNS[1].pattern",
|
||||||
|
"source": "(?:aws_secret(?:_access)?_key|AWS_SECRET(?:_ACCESS)?_KEY)\\s*[=:]\\s*['\"]?[0-9a-zA-Z/+=]{40}['\"]?",
|
||||||
|
"flags": "i"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "secret-egress:SECRET_PATTERNS[10].pattern",
|
||||||
|
"source": "\\bAIza[0-9A-Za-z_-]{35}\\b",
|
||||||
|
"flags": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "secret-egress:SECRET_PATTERNS[11].pattern",
|
||||||
|
"source": "-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----",
|
||||||
|
"flags": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "secret-egress:SECRET_PATTERNS[12].pattern",
|
||||||
|
"source": "JWT[_-]?SECRET\\s*[=:]\\s*['\"][^'\"]{8,}['\"]",
|
||||||
|
"flags": "i"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "secret-egress:SECRET_PATTERNS[13].pattern",
|
||||||
|
"source": "https:\\/\\/(?:hooks\\.slack\\.com\\/services|discord(?:app)?\\.com\\/api\\/webhooks)\\/",
|
||||||
|
"flags": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "secret-egress:SECRET_PATTERNS[14].pattern",
|
||||||
|
"source": "(?:password|passwd|secret|token|api[_-]?key)\\s*[=:]\\s*['\"][^'\"]{8,}['\"]",
|
||||||
|
"flags": "i"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "secret-egress:SECRET_PATTERNS[15].pattern",
|
||||||
|
"source": "[Bb]earer [A-Za-z0-9\\-._~+/]{20,}",
|
||||||
|
"flags": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "secret-egress:SECRET_PATTERNS[16].pattern",
|
||||||
|
"source": "(?:postgres|mysql|mongodb|redis):\\/\\/[^\\s]+@[^\\s]+",
|
||||||
|
"flags": "i"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "secret-egress:SECRET_PATTERNS[17].pattern",
|
||||||
|
"source": "\\bsk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}\\b",
|
||||||
|
"flags": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "secret-egress:SECRET_PATTERNS[18].pattern",
|
||||||
|
"source": "\\beyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\b",
|
||||||
|
"flags": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "secret-egress:SECRET_PATTERNS[2].pattern",
|
||||||
|
"source": "(?:AccountKey|SharedAccessKey|sig)=[A-Za-z0-9+/=]{20,}",
|
||||||
|
"flags": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "secret-egress:SECRET_PATTERNS[3].pattern",
|
||||||
|
"source": "(?:client[_-]?secret|ClientSecret)\\s*[=:]\\s*['\"][^'\"]{8,}['\"]",
|
||||||
|
"flags": "i"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "secret-egress:SECRET_PATTERNS[4].pattern",
|
||||||
|
"source": "Ocp-Apim-Subscription-Key\\s*[=:]\\s*['\"]?[0-9a-f]{32}['\"]?",
|
||||||
|
"flags": "i"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "secret-egress:SECRET_PATTERNS[5].pattern",
|
||||||
|
"source": "(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,}",
|
||||||
|
"flags": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "secret-egress:SECRET_PATTERNS[6].pattern",
|
||||||
|
"source": "npm_[A-Za-z0-9]{36}",
|
||||||
|
"flags": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "secret-egress:SECRET_PATTERNS[7].pattern",
|
||||||
|
"source": "\\bsk-ant-api03-[A-Za-z0-9_-]{93}\\b",
|
||||||
|
"flags": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "secret-egress:SECRET_PATTERNS[8].pattern",
|
||||||
|
"source": "\\bsk-proj-[A-Za-z0-9_-]{40,}\\b",
|
||||||
|
"flags": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "regex",
|
||||||
|
"key": "secret-egress:SECRET_PATTERNS[9].pattern",
|
||||||
|
"source": "\\bgithub_pat_[A-Za-z0-9_]{82}\\b",
|
||||||
|
"flags": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"kind": "table",
|
"kind": "table",
|
||||||
"key": "severity:OWASP_AGENTIC_MAP",
|
"key": "severity:OWASP_AGENTIC_MAP",
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@
|
||||||
|
|
||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { runHook } from './hook-helper.mjs';
|
import { runHook } from './hook-helper.mjs';
|
||||||
|
import { SECRET_PATTERNS } from '../../scanners/lib/secret-egress.mjs';
|
||||||
|
|
||||||
const SCRIPT = resolve(import.meta.dirname, '../../hooks/scripts/pre-edit-secrets.mjs');
|
const SCRIPT = resolve(import.meta.dirname, '../../hooks/scripts/pre-edit-secrets.mjs');
|
||||||
|
|
||||||
|
|
@ -316,3 +318,91 @@ describe('pre-edit-secrets — ALLOW cases', () => {
|
||||||
assert.equal(result.code, 0);
|
assert.equal(result.code, 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Vendored-commons swap (v8 Phase 5) — the fixed table now comes from
|
||||||
|
// `signatures/secret-egress.json` via scanners/lib/secret-egress.mjs.
|
||||||
|
//
|
||||||
|
// Coverage is derived from the LOADED TABLE, not from a hand-written list:
|
||||||
|
// every entry commons publishes must carry an end-to-end probe here, so a
|
||||||
|
// pattern added upstream cannot arrive without one. (The v7.8.3 ReDoS gate
|
||||||
|
// timed "every pattern" against a corpus that reached 8 of 45 — asserting on
|
||||||
|
// the subject instead of on a remembered list is the fix for that class.)
|
||||||
|
//
|
||||||
|
// Each probe is assembled at runtime: this file is NOT excluded by the hook's
|
||||||
|
// own exclusion list (`.(test|spec|mock).[jt]sx?` does not match `.test.mjs`),
|
||||||
|
// so a literal credential shape in this source would block writes to it.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const PROBES = {
|
||||||
|
'AWS Access Key ID': `const k = "${awsKeyId}";`,
|
||||||
|
'AWS Secret Access Key': awsSecretLine,
|
||||||
|
'Azure Connection String (AccountKey/SharedAccessKey/sig)':
|
||||||
|
['Account', 'Key=', 'A'.repeat(24)].join(''),
|
||||||
|
'Azure AD ClientSecret': ['client_', 'secret: "', 'abcdefghij', '"'].join(''),
|
||||||
|
'Azure AI Services Key':
|
||||||
|
['Ocp-Apim-Subscription-', 'Key: "', '0123456789abcdef'.repeat(2), '"'].join(''),
|
||||||
|
'GitHub Token': `const t = "${ghToken}";`,
|
||||||
|
'npm Token': ['const t = "', 'npm_', 'a1'.repeat(18), '";'].join(''),
|
||||||
|
'Anthropic API Key': `const k = "${anthropicKey}";`,
|
||||||
|
'OpenAI Project Key': `const k = "${openaiProjKey}";`,
|
||||||
|
'GitHub Fine-Grained PAT': `const k = "${githubFinePat}";`,
|
||||||
|
'Google API Key': `const k = "${googleApiKey}";`,
|
||||||
|
'Private Key PEM Block': ['-----BEGIN ', 'RSA PRIVATE KEY-----'].join(''),
|
||||||
|
'JWT Secret': ['JWT_', 'SECRET = "', 'longvalue123', '"'].join(''),
|
||||||
|
'Slack/Discord Webhook URL':
|
||||||
|
['https://hooks.', 'slack.com/services/T00000000/B00000000/abcdefgh'].join(''),
|
||||||
|
'Generic credential assignment': pwdLine,
|
||||||
|
'Authorization header with token': bearerLine,
|
||||||
|
'Database connection string': ['postgres', '://user:pw@localhost:5432/appdb'].join(''),
|
||||||
|
'OpenAI Legacy API Key': openaiLegacyKey,
|
||||||
|
'JWT (three-part token)': `const t = "${jwtToken}";`,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('pre-edit-secrets — vendored commons table', () => {
|
||||||
|
it('publishes a well-formed entry for every pattern commons ships', () => {
|
||||||
|
assert.ok(SECRET_PATTERNS.length > 0, 'table is empty — commons unresolvable?');
|
||||||
|
for (const entry of SECRET_PATTERNS) {
|
||||||
|
assert.equal(typeof entry.name, 'string');
|
||||||
|
assert.ok(entry.name.length > 0);
|
||||||
|
assert.ok(entry.pattern instanceof RegExp, `${entry.name}: pattern is not a RegExp`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has an end-to-end probe for every published entry', () => {
|
||||||
|
assert.deepEqual(SECRET_PATTERNS.map((p) => p.name), Object.keys(PROBES));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [name, content] of Object.entries(PROBES)) {
|
||||||
|
it(`blocks — and labels — ${name}`, async () => {
|
||||||
|
const result = await runHook(SCRIPT, writePayload('src/config.js', content));
|
||||||
|
assert.equal(result.code, 2, `expected BLOCK for ${name}`);
|
||||||
|
assert.ok(
|
||||||
|
result.stderr.includes(`BLOCKED: Potential secret detected — ${name}`),
|
||||||
|
`expected label ${JSON.stringify(name)}, got: ${result.stderr.split('\n')[0]}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// `ordering.last_entry_is_load_bearing` in the commons artifact: a JWT inside
|
||||||
|
// an Authorization header must report as the header, not as a bare JWT. This
|
||||||
|
// is what a silent reorder through a JSON round-trip would break.
|
||||||
|
it('reports a Bearer-wrapped JWT as the Authorization header, not as a JWT', async () => {
|
||||||
|
const result = await runHook(SCRIPT, writePayload(
|
||||||
|
'src/api.js',
|
||||||
|
['Authorization: Bearer ', jwtToken].join('')
|
||||||
|
));
|
||||||
|
assert.equal(result.code, 2);
|
||||||
|
assert.match(result.stderr, /Authorization header with token/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps no fixed regex literals in the hook itself', () => {
|
||||||
|
const src = readFileSync(SCRIPT, 'utf8');
|
||||||
|
const fixed = src.slice(0, src.indexOf('function isExcluded'));
|
||||||
|
assert.doesNotMatch(
|
||||||
|
fixed,
|
||||||
|
/name:\s*'[^']+',\s*pattern:\s*\//,
|
||||||
|
'hook still carries an inline fixed pattern literal — the swap is incomplete'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
106
tests/lib/secret-egress.test.mjs
Normal file
106
tests/lib/secret-egress.test.mjs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
// 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue