gendesign/scripts/claude-hooks/check-secret-read.py

95 lines
3.5 KiB
Python

#!/usr/bin/env python3
"""
Claude Code PreToolUse hook — blocks Bash commands that READ or TRANSMIT secret
files (.env*, .pgpass, ssh private keys).
Why: `.claude/settings.json` permissions end with a blanket `Bash(*)` allow, which
sidesteps the `Read(./.env*)` deny rule — `cat backend/.env`, `Get-Content .env`,
`curl -F @backend/.env …` all exfiltrate prod DB passwords + FORGEJO/GLITCHTIP tokens
without a single prompt. This hook closes that bypass at the command layer.
Wired in `.claude/settings.json` hooks.PreToolUse with matcher "Bash".
Behavior:
- Reads hook input JSON from stdin; extracts tool_input.command.
- Blocks (exit 2, stderr -> Claude) iff the command references a REAL secret file
AND uses a read/transmit verb, OR `< file` redirection, OR `@file` upload, OR `open(`.
- Template files (.env.example / .sample / .template / .dist / .md) are allowed.
- `ls`/`stat`/`test -f` on a secret file are allowed (they don't read content).
- Fail-OPEN on parse errors (exit 0): this is a tripwire layered on top of the deny
list + (future) sandbox, not the sole guard — better to under-block than to wedge
every Bash call on a malformed payload.
"""
from __future__ import annotations
import json
import re
import sys
# A secret-file token: `.env` with any number of dotted segments, `.pgpass`, or an
# ssh private key. The leading delimiter ([start | space | quote | = : < @ ( / ])
# anchors it as a filename/path component, not a substring of another word.
SECRET_RE = re.compile(
r"(?:^|[\s'\"=:<@(/])"
r"(\.env(?:\.[\w-]+)*|\.pgpass|id_rsa[\w.]*|id_ed25519[\w.]*|id_ecdsa[\w.]*)"
r"(?![\w-])",
re.IGNORECASE,
)
TEMPLATE_SUFFIXES = (".example", ".sample", ".template", ".dist", ".md")
# Verbs that read a file's content or push it off-box.
READ_VERB_RE = re.compile(
r"(?i)(?<![\w-])("
r"cat|tac|nl|head|tail|less|more|od|xxd|hexdump|strings|base64|"
r"type|gc|get-content|gp|select-string|sls|"
r"cp|copy|scp|rsync|sftp|"
r"curl|wget|invoke-webrequest|iwr|"
r"sed|awk|grep|findstr|sort|paste|cut|tr|dd|source|\."
r")(?![\w-])"
)
def _is_template(token: str) -> bool:
low = token.lower()
return any(low.endswith(s) for s in TEMPLATE_SUFFIXES)
def main() -> int:
try:
payload = json.load(sys.stdin)
except Exception:
return 0
tool = payload.get("tool_name") or payload.get("tool") or ""
if tool != "Bash":
return 0
cmd = (payload.get("tool_input") or {}).get("command") or ""
if not cmd:
return 0
hits = [m.group(1) for m in SECRET_RE.finditer(cmd) if not _is_template(m.group(1))]
if not hits:
return 0
danger = (
READ_VERB_RE.search(cmd)
or re.search(r"<\s*\S*\.env\b", cmd, re.IGNORECASE)
or re.search(r"@\S*\.env\b", cmd, re.IGNORECASE)
or re.search(r"open\(\s*['\"][^'\"]*\.env", cmd, re.IGNORECASE)
)
if not danger:
return 0
msg = [
f"Blocked: команда читает/передаёт секрет-файл ({hits[0]}).",
"Blanket `Bash(*)` обходит `Read(./.env*)` deny — этот PreToolUse-hook закрывает дыру.",
"Секрет-файлы содержат prod DB-пароли + FORGEJO/GLITCHTIP токены.",
"Если правда нужно прочитать — сделай это вручную вне Claude, либо используй `.env.example`.",
]
print("\n".join(msg), file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())