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
102 lines
5.2 KiB
JavaScript
102 lines
5.2 KiB
JavaScript
#!/usr/bin/env node
|
|
// Hook: pre-edit-secrets.mjs (consolidated)
|
|
// Event: PreToolUse (Edit|Write)
|
|
// Purpose: Detect secrets/credentials in file content before writing.
|
|
// Consolidates patterns from global, kiur, llm-security, and ms-ai-architect.
|
|
//
|
|
// Protocol:
|
|
// - Read JSON from stdin: { tool_name, tool_input }
|
|
// - tool_input.file_path — destination path
|
|
// - tool_input.content — full content (Write)
|
|
// - tool_input.new_string — replacement text (Edit)
|
|
// - Block: stderr + exit 2
|
|
// - Allow: exit 0
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { normalize } from 'node:path';
|
|
import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Secret detection patterns (union of global, kiur, llm-security, ms-ai-architect)
|
|
// ---------------------------------------------------------------------------
|
|
const SECRET_PATTERNS = [
|
|
{ name: 'AWS Access Key ID', pattern: /AKIA[0-9A-Z]{16}/ },
|
|
{ name: 'AWS Secret Access Key', pattern: /(?:aws_secret(?:_access)?_key|AWS_SECRET(?:_ACCESS)?_KEY)\s*[=:]\s*['"]?[0-9a-zA-Z/+=]{40}['"]?/i },
|
|
{ name: 'Azure Connection String (AccountKey/SharedAccessKey/sig)', pattern: /(?:AccountKey|SharedAccessKey|sig)=[A-Za-z0-9+/=]{20,}/ },
|
|
{ name: 'Azure AD ClientSecret', pattern: /(?:client[_-]?secret|ClientSecret)\s*[=:]\s*['"][^'"]{8,}['"]/i },
|
|
{ name: 'Azure AI Services Key', pattern: /Ocp-Apim-Subscription-Key\s*[=:]\s*['"]?[0-9a-f]{32}['"]?/i },
|
|
{ name: 'GitHub Token', pattern: /(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,}/ },
|
|
{ name: 'npm Token', pattern: /npm_[A-Za-z0-9]{36}/ },
|
|
// v7.8.3 #13 — bare provider keys (see knowledge/secrets-patterns.md).
|
|
// Previously these were caught only when wrapped in a quoted label
|
|
// assignment (password|secret|token|api_key = "..."); the bare key forms
|
|
// slipped through.
|
|
{ name: 'Anthropic API Key', pattern: /\bsk-ant-api03-[A-Za-z0-9_-]{93}\b/ },
|
|
{ name: 'OpenAI Project Key', pattern: /\bsk-proj-[A-Za-z0-9_-]{40,}\b/ },
|
|
{ name: 'GitHub Fine-Grained PAT', pattern: /\bgithub_pat_[A-Za-z0-9_]{82}\b/ },
|
|
{ name: 'Google API Key', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
{ name: 'Private Key PEM Block', pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/ },
|
|
{ name: 'JWT Secret', pattern: /JWT[_-]?SECRET\s*[=:]\s*['"][^'"]{8,}['"]/i },
|
|
{ name: 'Slack/Discord Webhook URL', pattern: /https:\/\/(?:hooks\.slack\.com\/services|discord(?:app)?\.com\/api\/webhooks)\// },
|
|
{ name: 'Generic credential assignment', pattern: /(?:password|passwd|secret|token|api[_-]?key)\s*[=:]\s*['"][^'"]{8,}['"]/i },
|
|
{ name: 'Authorization header with token', pattern: /[Bb]earer [A-Za-z0-9\-._~+/]{20,}/ },
|
|
{ name: 'Database connection string', pattern: /(?:postgres|mysql|mongodb|redis):\/\/[^\s]+@[^\s]+/i },
|
|
// OpenAI legacy API key (pre-2024 sk-<48 chars> shape). Anchored on the
|
|
// T3BlbkFJ base64 "OpenAI" watermark embedded mid-token rather than a
|
|
// bare sk-+48alnum shape, which would collide with sk-ant-/sk-proj- and
|
|
// other unrelated sk-* tokens. Recall gap: bare/unquoted legacy keys
|
|
// (no label assignment, no Bearer prefix) previously slipped through.
|
|
{ name: 'OpenAI Legacy API Key', pattern: /\bsk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}\b/ },
|
|
// v7.8.3 #13 — three-part JWT (header.payload.signature, base64url). The
|
|
// 10-char part minimum keeps prose fragments (eyJabc.def.ghi) from tripping.
|
|
// Kept last so a Bearer-header context reports as 'Authorization header'.
|
|
{ name: 'JWT (three-part token)', pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ },
|
|
// Policy-defined additional patterns
|
|
...getPolicyValue('secrets', 'additional_patterns', []).map((p, i) => ({
|
|
name: `Custom pattern ${i + 1}`,
|
|
pattern: new RegExp(p),
|
|
})),
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Exclusions: files that may contain example patterns for documentation
|
|
// ---------------------------------------------------------------------------
|
|
function isExcluded(filePath) {
|
|
if (!filePath) return false;
|
|
const n = normalize(filePath);
|
|
if (/[\\/]knowledge[\\/].+\.md$/i.test(n)) return true;
|
|
if (/[\\/]references[\\/].+\.md$/i.test(n)) return true;
|
|
if (/\.(test|spec|mock)\.[jt]sx?$/.test(n)) return true;
|
|
if (/\.(example|template|sample)(\.|$)/.test(n)) return true;
|
|
return false;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
// ---------------------------------------------------------------------------
|
|
let input;
|
|
try {
|
|
const raw = readFileSync(0, 'utf-8');
|
|
input = JSON.parse(raw);
|
|
} catch { process.exit(0); }
|
|
|
|
const toolInput = input?.tool_input ?? {};
|
|
const filePath = toolInput.file_path ?? '';
|
|
|
|
if (isExcluded(filePath)) process.exit(0);
|
|
|
|
const contentToCheck = [toolInput.content ?? '', toolInput.new_string ?? ''].join('\n');
|
|
if (!contentToCheck.trim()) process.exit(0);
|
|
|
|
for (const { name, pattern } of SECRET_PATTERNS) {
|
|
if (pattern.test(contentToCheck)) {
|
|
process.stderr.write(
|
|
`BLOCKED: Potential secret detected — ${name}\n` +
|
|
` File: ${filePath || '(unknown)'}\n` +
|
|
` Remove the credential before writing. Use <YOUR_KEY_HERE> or .env.\n`
|
|
);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
process.exit(0);
|