feat: secret-detection and sensitive-file-guard hooks (#3)

This commit is contained in:
duthaho
2026-07-17 06:29:17 +07:00
committed by GitHub
parent f9841971cc
commit 8710df2704
8 changed files with 256 additions and 8 deletions
+53
View File
@@ -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();
+52
View File
@@ -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();
+127
View File
@@ -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);