95 lines
3.5 KiB
Python
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())
|