Bare/unquoted legacy OpenAI keys (no label assignment, no Bearer prefix) slipped past the pre-write secret-detection hook. Added a pattern anchored on the T3BlbkFJ base64 "OpenAI" watermark (vendor-documented shape), avoiding the collision-prone bare sk-+48alnum form. Failing tests first, full suite green (2184/0/6). The originally planned source for this fix — porting two entries from commons' secret-egress.json — turned out to be a false premise: that file is a byte-identical copy of this hook's own table, not a superset. The two missing names existed only as prose in commons' conformance/manifest.json, describing a different repo's (the guard's) unpublished Python table. gcp-service-account-json was measured NOT to be a gap (already covered by the existing PEM-block pattern); openai-api-key-legacy was the one real gap, closed here with a locally-authored pattern rather than an invented "port". Commons notified via coord-send that their secret-egress.json (count: 18) is now stale. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PYJX35KLH3rpS6pi7LHj8u
318 lines
12 KiB
JavaScript
318 lines
12 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 { 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('');
|
|
|
|
// 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);
|
|
});
|
|
});
|