// 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' ); }); });