llm-security/tests/hooks/pre-edit-secrets.test.mjs
Kjell Tore Guttormsen c9652a6d3d 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
2026-08-13 21:10:14 +02:00

408 lines
16 KiB
JavaScript

// pre-edit-secrets.test.mjs — Tests for hooks/scripts/pre-edit-secrets.mjs
// Zero external dependencies: node:test + node:assert only.
//
// Fake credentials are assembled ONLY at runtime so this source file cannot
// self-trigger the pre-edit-secrets hook when written by Claude Code.
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');
// ---------------------------------------------------------------------------
// Runtime-assembled fake credentials (no literal patterns in source)
// ---------------------------------------------------------------------------
// AWS key ID: AKIA + 16 uppercase alphanumeric chars
const awsKeyId = ['AKIA', 'IOSFODNN7EXAMPLE'].join(''); // 20 chars total
// AWS secret: keyword + 40-char base64-ish value
const awsSecretLine = [
'aws_secret_access_key = "',
'abcdefghij1234567890ABCDEFGHIJ1234567890',
'"',
].join('');
// GitHub token: ghp_ prefix + 36 alphanum chars (total >= 40)
const ghToken = ['ghp_', 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij'].join('');
// Generic password assignment (>= 8 char value)
const pwdLine = ['pass', 'word', ' = "longvalue123456789"'].join('');
// Bearer token (>= 20 non-space chars after "Bearer ")
const bearerLine = [
'Authorization: Bearer ',
'eyJhbGciOiJSUzI1NiJ9.payload.sig12345678',
].join('');
// v7.8.3 #13 — bare provider keys (documented in knowledge/secrets-patterns.md)
// that previously slipped through unless wrapped in a quoted label assignment.
// Anthropic API key: sk-ant-api03- + 93 chars of [A-Za-z0-9_-]
const anthropicKey = ['sk-ant-', 'api03-', 'x'.repeat(92) + 'Q'].join('');
// OpenAI project key: sk-proj- + >= 40 chars of [A-Za-z0-9_-]
const openaiProjKey = ['sk-', 'proj-', 'Ab1'.repeat(14)].join('');
// GitHub fine-grained PAT: github_pat_ + 82 chars of [A-Za-z0-9_]
const githubFinePat = ['github_', 'pat_', 'A1'.repeat(41)].join('');
// Google API key: AIza + exactly 35 chars of [0-9A-Za-z_-]
const googleApiKey = ['AIza', 'Sy' + 'D'.repeat(33)].join('');
// JWT: three base64url parts separated by dots, header starts with eyJ
const jwtToken = [
'eyJ', 'hbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9',
'.', 'eyJzdWIiOiIxMjM0NTY3ODkwIn0',
'.', 'TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ',
].join('');
// OpenAI legacy API key: sk- + 20 chars + the base64 "OpenAI" watermark
// (T3BlbkFJ) + 20 more chars. The watermark anchor is what the shape check
// keys on, per vendor-documented format (avoids the bare sk-+48alnum shape,
// which collides with sk-ant-/sk-proj- and other unrelated sk-* tokens).
const openaiLegacyKey = [
'sk-', 'a'.repeat(20), 'T3BlbkFJ', 'b'.repeat(20),
].join('');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function writePayload(filePath, content) {
return { tool_name: 'Write', tool_input: { file_path: filePath, content } };
}
function editPayload(filePath, newString) {
return { tool_name: 'Edit', tool_input: { file_path: filePath, new_string: newString } };
}
// ---------------------------------------------------------------------------
// BLOCK cases
// ---------------------------------------------------------------------------
describe('pre-edit-secrets — BLOCK cases', () => {
it('blocks a Write containing an AWS Access Key ID pattern', async () => {
const result = await runHook(SCRIPT, writePayload(
'src/config.js',
`const key = "${awsKeyId}";`
));
assert.equal(result.code, 2);
assert.match(result.stderr, /BLOCKED/);
assert.match(result.stderr, /AWS Access Key ID/);
});
it('blocks a Write containing an AWS Secret Access Key assignment', async () => {
const result = await runHook(SCRIPT, writePayload('src/config.js', awsSecretLine));
assert.equal(result.code, 2);
assert.match(result.stderr, /BLOCKED/);
assert.match(result.stderr, /AWS Secret Access Key/);
});
it('blocks a Write containing a GitHub token pattern', async () => {
const result = await runHook(SCRIPT, writePayload(
'src/config.js',
`const t = "${ghToken}";`
));
assert.equal(result.code, 2);
assert.match(result.stderr, /BLOCKED/);
assert.match(result.stderr, /GitHub Token/);
});
it('blocks a Write containing a generic password assignment with a long value', async () => {
const result = await runHook(SCRIPT, writePayload('src/config.js', pwdLine));
assert.equal(result.code, 2);
assert.match(result.stderr, /BLOCKED/);
assert.match(result.stderr, /Generic credential assignment/);
});
it('blocks a Write containing a Bearer token in an Authorization header', async () => {
const result = await runHook(SCRIPT, writePayload('src/api.js', bearerLine));
assert.equal(result.code, 2);
assert.match(result.stderr, /BLOCKED/);
assert.match(result.stderr, /Authorization header/);
});
it('blocks an Edit where new_string contains an AWS Access Key ID pattern', async () => {
const result = await runHook(SCRIPT, editPayload(
'src/config.js',
`const accessKey = "${awsKeyId}";`
));
assert.equal(result.code, 2);
assert.match(result.stderr, /BLOCKED/);
});
});
// ---------------------------------------------------------------------------
// v7.8.3 #13 — bare provider keys must block WITHOUT a label assignment
// ---------------------------------------------------------------------------
describe('pre-edit-secrets — bare provider keys (#13)', () => {
it('blocks a bare Anthropic API key (sk-ant-api03-...)', async () => {
const result = await runHook(SCRIPT, writePayload(
'src/config.js',
`const k = "${anthropicKey}";`
));
assert.equal(result.code, 2);
assert.match(result.stderr, /BLOCKED/);
assert.match(result.stderr, /Anthropic/);
});
it('blocks a bare OpenAI project key (sk-proj-...)', async () => {
const result = await runHook(SCRIPT, writePayload(
'src/config.js',
`const k = "${openaiProjKey}";`
));
assert.equal(result.code, 2);
assert.match(result.stderr, /BLOCKED/);
assert.match(result.stderr, /OpenAI/);
});
it('blocks a bare fine-grained GitHub PAT (github_pat_...)', async () => {
const result = await runHook(SCRIPT, writePayload(
'src/config.js',
`const k = "${githubFinePat}";`
));
assert.equal(result.code, 2);
assert.match(result.stderr, /BLOCKED/);
assert.match(result.stderr, /GitHub/);
});
it('blocks a bare Google API key (AIza...)', async () => {
const result = await runHook(SCRIPT, writePayload(
'src/config.js',
`const k = "${googleApiKey}";`
));
assert.equal(result.code, 2);
assert.match(result.stderr, /BLOCKED/);
assert.match(result.stderr, /Google/);
});
it('blocks a bare JWT (eyJ...header.payload.signature)', async () => {
const result = await runHook(SCRIPT, writePayload(
'src/config.js',
`const t = "${jwtToken}";`
));
assert.equal(result.code, 2);
assert.match(result.stderr, /BLOCKED/);
assert.match(result.stderr, /JWT/);
});
// FP probes — prose mentioning the prefixes must not trip the patterns.
it('allows prose mentioning the sk-ant- prefix without a key body', async () => {
const result = await runHook(SCRIPT, writePayload(
'docs/notes.md',
'Anthropic keys start with sk-ant- and must never be committed.'
));
assert.equal(result.code, 0);
});
it('allows prose mentioning the AIza prefix without a key body', async () => {
const result = await runHook(SCRIPT, writePayload(
'docs/notes.md',
'Google API keys have the AIza prefix (39 chars total).'
));
assert.equal(result.code, 0);
});
it('allows prose mentioning the github_pat_ prefix without a key body', async () => {
const result = await runHook(SCRIPT, writePayload(
'docs/notes.md',
'Fine-grained PATs use the github_pat_ prefix.'
));
assert.equal(result.code, 0);
});
it('allows prose mentioning eyJ with short/dotted fragments (not a real JWT)', async () => {
const result = await runHook(SCRIPT, writePayload(
'docs/notes.md',
'JWTs start with eyJ... e.g. eyJabc.def.ghi is too short to be real.'
));
assert.equal(result.code, 0);
});
});
// ---------------------------------------------------------------------------
// OpenAI legacy API key — recall gap. A bare/unquoted legacy key (no label
// assignment, no Bearer prefix) previously slipped through: sk-ant-api03-
// and sk-proj- are covered, but the pre-2024 sk-<48 chars> shape had no
// dedicated pattern and only 'Generic credential assignment' or
// 'Authorization header with token' happened to catch it in labeled/header
// contexts — not when it appears bare, e.g. an unquoted env assignment.
// ---------------------------------------------------------------------------
describe('pre-edit-secrets — OpenAI legacy key recall gap', () => {
it('blocks a bare legacy OpenAI key in an unquoted env assignment', async () => {
const result = await runHook(SCRIPT, writePayload(
'.env',
`OPENAI_API_KEY=${openaiLegacyKey}`
));
assert.equal(result.code, 2);
assert.match(result.stderr, /BLOCKED/);
assert.match(result.stderr, /OpenAI/);
});
it('blocks a bare legacy OpenAI key with no surrounding context at all', async () => {
const result = await runHook(SCRIPT, writePayload('notes.txt', openaiLegacyKey));
assert.equal(result.code, 2);
assert.match(result.stderr, /BLOCKED/);
assert.match(result.stderr, /OpenAI/);
});
it('allows prose mentioning the sk- prefix without the T3BlbkFJ watermark', async () => {
const result = await runHook(SCRIPT, writePayload(
'docs/notes.md',
`Legacy OpenAI keys start with sk- followed by 48 characters, e.g. sk-${'x'.repeat(48)}.`
));
assert.equal(result.code, 0);
});
});
// ---------------------------------------------------------------------------
// ALLOW cases
// ---------------------------------------------------------------------------
describe('pre-edit-secrets — ALLOW cases', () => {
it('allows a generic pattern where the value is shorter than 8 characters', async () => {
const result = await runHook(SCRIPT, writePayload('src/config.js', 'x = "abc"'));
assert.equal(result.code, 0);
});
it('allows a Write to a file in /project/knowledge/ (absolute path) even if content matches a secret pattern', async () => {
// The exclusion pattern requires a directory separator before "knowledge"
const result = await runHook(SCRIPT, {
tool_name: 'Write',
tool_input: { file_path: '/project/knowledge/aws-docs.md', content: `Example: ${awsKeyId}` },
});
assert.equal(result.code, 0);
});
it('allows a Write to a .test.js file even if content matches a secret pattern', async () => {
// The exclusion matches .(test|spec|mock).[jt]sx? — covers .test.js but not .test.mjs
const result = await runHook(SCRIPT, {
tool_name: 'Write',
tool_input: { file_path: 'tests/config.test.js', content: `const k = "${awsKeyId}"; // fixture` },
});
assert.equal(result.code, 0);
});
it('allows a Write to a .example file even if content matches a secret pattern', async () => {
const result = await runHook(SCRIPT, {
tool_name: 'Write',
tool_input: { file_path: 'config.example', content: pwdLine },
});
assert.equal(result.code, 0);
});
it('allows a Write with content that contains no secrets', async () => {
const result = await runHook(SCRIPT, writePayload('src/app.js', 'console.log("Hello");'));
assert.equal(result.code, 0);
});
it('allows a Write with empty content', async () => {
const result = await runHook(SCRIPT, writePayload('src/app.js', ''));
assert.equal(result.code, 0);
});
it('allows a Write where the content field is absent', async () => {
const result = await runHook(SCRIPT, { tool_name: 'Write', tool_input: { file_path: 'src/app.js' } });
assert.equal(result.code, 0);
});
it('exits 0 gracefully when stdin is not valid JSON', async () => {
const result = await runHook(SCRIPT, 'this is not json {{{');
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'
);
});
});