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
|
|
@ -6,8 +6,10 @@
|
|||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
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');
|
||||
|
||||
|
|
@ -316,3 +318,91 @@ describe('pre-edit-secrets — ALLOW cases', () => {
|
|||
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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue