From 8710df2704662f75e6cadb008f4f2521b4f52235 Mon Sep 17 00:00:00 2001 From: duthaho Date: Fri, 17 Jul 2026 06:29:17 +0700 Subject: [PATCH] feat: secret-detection and sensitive-file-guard hooks (#3) --- README.md | 2 +- scripts/detect-secrets.cjs | 53 ++++++++ scripts/guard-sensitive-files.cjs | 52 +++++++ scripts/test-hooks.cjs | 127 ++++++++++++++++++ skills/init/SKILL.md | 14 +- skills/init/templates/hooks.json | 12 ++ .../docs/getting-started/configuration.md | 2 + .../docs/getting-started/installation.md | 2 +- 8 files changed, 256 insertions(+), 8 deletions(-) create mode 100644 scripts/detect-secrets.cjs create mode 100644 scripts/guard-sensitive-files.cjs create mode 100644 scripts/test-hooks.cjs diff --git a/README.md b/README.md index 853ec8b..da59a31 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Each agent has a single dispatcher and a clear job. No agent-bloat. | Category | What | Location | |---|---|---| | **Rules** | API, frontend, migrations, security, testing | `.claude/rules/` | -| **Hooks** | auto-format, block-dangerous-commands, notifications | `.claude/hooks/` + `settings.local.json` | +| **Hooks** | auto-format, block-dangerous-commands, detect-secrets, guard-sensitive-files, notifications | `.claude/hooks/` + `settings.local.json` | | **MCP Servers** | Context7, Sequential, Playwright, Memory, Filesystem | `.mcp.json` | Output styles ship with the plugin (in `output-styles/`) and are auto-discovered by Claude Code; no init step needed. diff --git a/scripts/detect-secrets.cjs b/scripts/detect-secrets.cjs new file mode 100644 index 0000000..013b3df --- /dev/null +++ b/scripts/detect-secrets.cjs @@ -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(); diff --git a/scripts/guard-sensitive-files.cjs b/scripts/guard-sensitive-files.cjs new file mode 100644 index 0000000..2cfb765 --- /dev/null +++ b/scripts/guard-sensitive-files.cjs @@ -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(); diff --git a/scripts/test-hooks.cjs b/scripts/test-hooks.cjs new file mode 100644 index 0000000..1e005b1 --- /dev/null +++ b/scripts/test-hooks.cjs @@ -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); diff --git a/skills/init/SKILL.md b/skills/init/SKILL.md index 6eaeb84..5a9f370 100644 --- a/skills/init/SKILL.md +++ b/skills/init/SKILL.md @@ -19,7 +19,7 @@ Output styles ship with the plugin and are auto-discovered by Claude Code (no in | Category | Files | Location | |----------|-------|----------| | Rules | api.md, frontend.md, migrations.md, security.md, testing.md | `.claude/rules/` | -| Hooks | auto-format, block-dangerous-commands, notify | `.claude/hooks/` + `settings.local.json` | +| Hooks | auto-format, block-dangerous-commands, detect-secrets, guard-sensitive-files, notify | `.claude/hooks/` + `settings.local.json` | | MCP Servers | context7, sequential, playwright, memory, filesystem | `.mcp.json` | --- @@ -49,9 +49,11 @@ For each selected rule, read the template from `${CLAUDE_PLUGIN_ROOT}/skills/ini "Which hooks do you want to install?" - a) Auto-format (runs linter after Write/Edit) - b) Block dangerous commands (prevents rm -rf /, force push main, etc.) -- c) Notifications (desktop notifications on completion) -- d) All of the above -- e) Skip hooks +- c) Detect secrets (blocks writes containing API keys, tokens, private key blocks) +- d) Guard sensitive files (blocks edits to .env files, key material, credential dotfiles) +- e) Notifications (desktop notifications on completion) +- f) All of the above +- g) Skip hooks For each selected hook: @@ -106,7 +108,7 @@ Print a summary table of everything installed: Claudekit setup complete! Rules: 5 installed → .claude/rules/ - Hooks: 3 installed → .claude/hooks/ + settings.local.json + Hooks: 5 installed → .claude/hooks/ + settings.local.json MCP: 5 configured → .mcp.json Next steps: @@ -122,7 +124,7 @@ Next steps: If `$ARGUMENTS` contains `--all`, skip all prompts and install everything: - All 5 rules -- All 3 hooks +- All 5 hooks - All 5 MCP servers --- diff --git a/skills/init/templates/hooks.json b/skills/init/templates/hooks.json index d0185ea..6ba92ba 100644 --- a/skills/init/templates/hooks.json +++ b/skills/init/templates/hooks.json @@ -11,6 +11,18 @@ "script": "block-dangerous-commands.cjs", "description": "Blocks rm -rf /, force push to main, hard reset, DROP TABLE, etc." }, + "detect-secrets": { + "event": "PreToolUse", + "matcher": "Write|Edit", + "script": "detect-secrets.cjs", + "description": "Blocks writes containing secret-looking content (API keys, tokens, private key blocks)" + }, + "guard-sensitive-files": { + "event": "PreToolUse", + "matcher": "Write|Edit", + "script": "guard-sensitive-files.cjs", + "description": "Blocks edits to .env files, key material (.pem/.key/id_rsa), and credential dotfiles" + }, "notify": { "event": "Notification", "matcher": "", diff --git a/website/src/content/docs/getting-started/configuration.md b/website/src/content/docs/getting-started/configuration.md index 70e75c4..fc6b7f4 100644 --- a/website/src/content/docs/getting-started/configuration.md +++ b/website/src/content/docs/getting-started/configuration.md @@ -53,6 +53,8 @@ Hooks run automatically in response to Claude Code events: |------|-------|-------------| | `auto-format` | After Write/Edit | Runs ruff (Python) or eslint (JS/TS) on changed files | | `block-dangerous-commands` | Before Bash | Blocks `rm -rf /`, force push to main, `DROP TABLE`, etc. | +| `detect-secrets` | Before Write/Edit | Blocks writes containing secret-looking content (API keys, tokens, private key blocks) | +| `guard-sensitive-files` | Before Write/Edit | Blocks edits to `.env` files, key material (`.pem`/`.key`/`id_rsa`), and credential dotfiles | | `notify` | Notification | Cross-platform desktop notifications | Hooks are installed to `.claude/hooks/` with config in `settings.local.json` (gitignored). diff --git a/website/src/content/docs/getting-started/installation.md b/website/src/content/docs/getting-started/installation.md index d72c4d7..a37a11d 100644 --- a/website/src/content/docs/getting-started/installation.md +++ b/website/src/content/docs/getting-started/installation.md @@ -42,7 +42,7 @@ The wizard interactively installs: |----------|------|----------| | **Rules** | API, frontend, migrations, security, testing | `.claude/rules/` | | **Modes** | brainstorm, deep-research, default, implementation, orchestration, review, token-efficient | `.claude/modes/` | -| **Hooks** | auto-format, block-dangerous-commands, notifications | `.claude/hooks/` + `settings.local.json` | +| **Hooks** | auto-format, block-dangerous-commands, detect-secrets, guard-sensitive-files, notifications | `.claude/hooks/` + `settings.local.json` | | **MCP Servers** | Context7, Sequential, Playwright, Memory, Filesystem | `.mcp.json` | Or install everything at once: