// 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 { resolve } from 'node:path'; import { runHook } from './hook-helper.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(''); // --------------------------------------------------------------------------- // 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); }); }); // --------------------------------------------------------------------------- // 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); }); });