mirror of
https://github.com/duthaho/claudekit.git
synced 2026-07-18 13:39:40 +03:00
feat: secret-detection and sensitive-file-guard hooks (#3)
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* PreToolUse hook (matcher: Write|Edit): blocks writes that contain
|
||||
* secret-looking material — API keys, tokens, private key blocks.
|
||||
* Exit 0 = allow, Exit 2 = block.
|
||||
* Fails open on errors (exit 0) so a hook bug never stalls the session.
|
||||
*
|
||||
* Patterns are precision-first (known key prefixes only, no entropy
|
||||
* heuristics): a blocking hook that cries wolf gets disabled. Extend the
|
||||
* list below for your org's token formats.
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const SECRET_PATTERNS = [
|
||||
{ name: "AWS access key", re: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/ },
|
||||
{ name: "GitHub token", re: /\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{40,})\b/ },
|
||||
{ name: "Slack token", re: /\bxox[a-z]-[A-Za-z0-9-]{10,}\b/ },
|
||||
{ name: "Google API key", re: /\bAIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])/ },
|
||||
{ name: "Stripe live key", re: /\b[sr]k_live_[0-9a-zA-Z]{16,}\b/ },
|
||||
{ name: "npm token", re: /\bnpm_[A-Za-z0-9]{30,}\b/ },
|
||||
{ name: "Anthropic API key", re: /\bsk-ant-[A-Za-z0-9_-]{16,}\b/ },
|
||||
{ name: "OpenAI API key", re: /\bsk-(proj-[A-Za-z0-9_-]{16,}|[A-Za-z0-9]{32,})\b/ },
|
||||
{ name: "private key block", re: /-----BEGIN [A-Z ]*PRIVATE KEY( BLOCK)?-----/ },
|
||||
];
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
let data = "";
|
||||
for await (const chunk of process.stdin) data += chunk;
|
||||
const input = JSON.parse(data);
|
||||
|
||||
const raw =
|
||||
input?.tool_input?.content ?? input?.tool_input?.new_string ?? "";
|
||||
const text = typeof raw === "string" ? raw : "";
|
||||
|
||||
for (const { name, re } of SECRET_PATTERNS) {
|
||||
if (re.test(text)) {
|
||||
// Name the family only — never echo the matched secret.
|
||||
console.error(
|
||||
`BLOCKED: content appears to contain a ${name}. Use an environment variable or secret manager instead.`
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} catch {
|
||||
// Fail open — never block on hook errors
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* PreToolUse hook (matcher: Write|Edit): blocks edits to sensitive files —
|
||||
* env files, private key material, credential dotfiles.
|
||||
* Exit 0 = allow, Exit 2 = block.
|
||||
* Fails open on errors (exit 0) so a hook bug never stalls the session.
|
||||
*
|
||||
* Template env files (.env.example / .env.sample / .env.template) stay
|
||||
* editable — they exist to be written.
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const ALLOWED = [/\.env\.(example|sample|template)$/i];
|
||||
|
||||
const SENSITIVE_PATTERNS = [
|
||||
{ name: "env file", re: /(^|\/)\.env(\.[^/]+)?$/i },
|
||||
{ name: "key material", re: /\.(pem|key|p12|pfx)$/i },
|
||||
{ name: "SSH private key", re: /(^|\/)id_(rsa|ed25519|ecdsa|dsa)[^/]*$/i },
|
||||
{ name: "credential file", re: /(^|\/)\.(netrc|npmrc|git-credentials)$/i },
|
||||
{ name: "AWS credentials", re: /(^|\/)\.aws\/credentials$/i },
|
||||
];
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
let data = "";
|
||||
for await (const chunk of process.stdin) data += chunk;
|
||||
const input = JSON.parse(data);
|
||||
|
||||
const raw = input?.tool_input?.file_path ?? "";
|
||||
// Normalize Windows separators so path-anchored patterns hold.
|
||||
const filePath = (typeof raw === "string" ? raw : "").replace(/\\/g, "/");
|
||||
if (!filePath) process.exit(0);
|
||||
|
||||
if (ALLOWED.some((re) => re.test(filePath))) process.exit(0);
|
||||
|
||||
for (const { name, re } of SENSITIVE_PATTERNS) {
|
||||
if (re.test(filePath)) {
|
||||
console.error(
|
||||
`BLOCKED: "${filePath}" is a ${name} — edit it manually, outside the agent session.`
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} catch {
|
||||
// Fail open — never block on hook errors
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Test runner for the PreToolUse hook scripts: pipes PreToolUse-shaped JSON
|
||||
* fixtures into each hook and asserts the exit code (0 = allow, 2 = block).
|
||||
* Zero dependencies. Exit 0 when every case passes, 1 otherwise.
|
||||
* A missing hook script or spawn error counts as that case's FAIL — the run
|
||||
* never aborts.
|
||||
*
|
||||
* Usage: node scripts/test-hooks.cjs
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const { spawnSync } = require("child_process");
|
||||
const path = require("path");
|
||||
|
||||
const write = (content, file_path = "src/app.js") => ({
|
||||
tool_name: "Write",
|
||||
tool_input: { file_path, content },
|
||||
});
|
||||
const edit = (new_string, file_path = "src/app.js", old_string = "old") => ({
|
||||
tool_name: "Edit",
|
||||
tool_input: { file_path, old_string, new_string },
|
||||
});
|
||||
// Fixture secrets are synthetic (right shape, fake values).
|
||||
const FAKE = {
|
||||
aws: "AKIA" + "ABCDEFGHIJKLMNOP",
|
||||
awsTemp: "ASIA" + "ABCDEFGHIJKLMNOP",
|
||||
githubPat: "github_pat_" + "11ABCDEFG0" + "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8s9T0u1V2w3X4y5Z6a7B8",
|
||||
slackRefresh: "xoxe-" + "1-My1234567890abcdefghijklmnop",
|
||||
googleDash: "AIza" + "SyA1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6-",
|
||||
openaiLegacy: "sk-" + "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8s9T0u1V2w3X4",
|
||||
pgp: "-----BEGIN PGP PRIVATE KEY BLOCK-----\nlQdGBF...\n-----END PGP PRIVATE KEY BLOCK-----",
|
||||
github: "ghp_" + "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8",
|
||||
slack: "xoxb-" + "123456789012-1234567890123-abcdefghijklmnopqrstuvwx",
|
||||
google: "AIza" + "SyA1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6q",
|
||||
stripe: "sk_live_" + "a1B2c3D4e5F6g7H8i9J0k1L2",
|
||||
npm: "npm_" + "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8",
|
||||
anthropic: "sk-ant-" + "api03-a1B2c3D4e5F6g7H8i9J0k1L2",
|
||||
openai: "sk-proj-" + "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6",
|
||||
pem: "-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n-----END RSA PRIVATE KEY-----",
|
||||
};
|
||||
|
||||
const CASES = {
|
||||
"detect-secrets.cjs": [
|
||||
// block — one per pattern family
|
||||
["AWS key in Write content", write(`const k = "${FAKE.aws}";`), 2],
|
||||
["GitHub token in Edit new_string", edit(`token: "${FAKE.github}"`), 2],
|
||||
["Slack token", write(`SLACK=${FAKE.slack}`), 2],
|
||||
["Google API key", write(`key=${FAKE.google}`), 2],
|
||||
["Stripe live key", write(`stripe: "${FAKE.stripe}"`), 2],
|
||||
["npm token", write(`//registry.npmjs.org/:_authToken=${FAKE.npm}`), 2],
|
||||
["Anthropic key", write(`ANTHROPIC_API_KEY=${FAKE.anthropic}`), 2],
|
||||
["OpenAI project key", write(`OPENAI_API_KEY=${FAKE.openai}`), 2],
|
||||
["private key block", write(FAKE.pem), 2],
|
||||
["AWS temporary key (ASIA)", write(`k="${FAKE.awsTemp}"`), 2],
|
||||
["GitHub fine-grained PAT", write(`t=${FAKE.githubPat}`), 2],
|
||||
["Slack refresh token (xoxe)", write(`SLACK_REFRESH=${FAKE.slackRefresh}`), 2],
|
||||
["Google key ending in dash", write(`k="${FAKE.googleDash}";`), 2],
|
||||
["OpenAI legacy key", write(`OPENAI_API_KEY="${FAKE.openaiLegacy}"`), 2],
|
||||
["PGP private key block", write(FAKE.pgp), 2],
|
||||
// allow
|
||||
["clean code", write("const x = 1;\nmodule.exports = x;"), 0],
|
||||
["prose mentioning the word secret", write("Keep your secret keys in a vault."), 0],
|
||||
["empty content", write(""), 0],
|
||||
["clean Edit with both strings", edit("const y = 2;"), 0],
|
||||
["non-string content", { tool_name: "Write", tool_input: { file_path: "a.js", content: 42 } }, 0],
|
||||
["malformed stdin", "{not json", 0],
|
||||
["empty stdin", "", 0],
|
||||
],
|
||||
"guard-sensitive-files.cjs": [
|
||||
// block
|
||||
[".env", write("X=1", ".env"), 2],
|
||||
[".env.production", write("X=1", ".env.production"), 2],
|
||||
[".env.local via Edit", edit("X=1", ".env.local"), 2],
|
||||
["nested config/.env", write("X=1", "config/.env"), 2],
|
||||
["server.pem", write("cert", "server.pem"), 2],
|
||||
["keys/signing.key", write("key", "keys/signing.key"), 2],
|
||||
["id_rsa", write("key", "id_rsa"), 2],
|
||||
[".ssh/id_ed25519", write("key", ".ssh/id_ed25519"), 2],
|
||||
[".npmrc", write("token", ".npmrc"), 2],
|
||||
[".netrc", write("login", ".netrc"), 2],
|
||||
[".aws/credentials", write("id", ".aws/credentials"), 2],
|
||||
[".git-credentials", write("url", ".git-credentials"), 2],
|
||||
["Windows path to .env", write("X=1", "C:\\proj\\.env"), 2],
|
||||
["uppercase ID_RSA", write("key", "ID_RSA"), 2],
|
||||
["uppercase .NPMRC", write("token", ".NPMRC"), 2],
|
||||
["uppercase .AWS/CREDENTIALS", write("id", ".AWS/CREDENTIALS"), 2],
|
||||
// allow
|
||||
[".env.example", write("X=", ".env.example"), 0],
|
||||
[".env.sample", write("X=", ".env.sample"), 0],
|
||||
[".env.template", write("X=", ".env.template"), 0],
|
||||
["ordinary source file", write("code", "src/app.js"), 0],
|
||||
["envy.js (no substring match)", write("code", "envy.js"), 0],
|
||||
["monkey.js (no .key suffix match)", write("code", "monkey.js"), 0],
|
||||
["missing file_path", { tool_name: "Write", tool_input: { content: "x" } }, 0],
|
||||
["malformed stdin", "{not json", 0],
|
||||
],
|
||||
};
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
for (const [script, cases] of Object.entries(CASES)) {
|
||||
console.log(`\n${script}`);
|
||||
const scriptPath = path.join(__dirname, script);
|
||||
for (const [name, fixture, expected] of cases) {
|
||||
const input = typeof fixture === "string" ? fixture : JSON.stringify(fixture);
|
||||
let status = null;
|
||||
let err = "";
|
||||
try {
|
||||
const r = spawnSync(process.execPath, [scriptPath], { input, timeout: 10000 });
|
||||
if (r.error) err = r.error.message;
|
||||
else status = r.status;
|
||||
} catch (e) {
|
||||
err = e.message;
|
||||
}
|
||||
const ok = !err && status === expected;
|
||||
if (ok) {
|
||||
pass++;
|
||||
console.log(` ✓ ${name} (exit ${status})`);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(` ✗ ${name} — expected exit ${expected}, got ${err || `exit ${status}`}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`\n${fail === 0 ? "OK" : "FAIL"} — ${pass} passed, ${fail} failed`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user