gendesign/scripts/claude-hooks/check-secret-read.py
bot-backend 0e4beb8457
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
chore(hooks): точечный allowlist на три ключа метрик вместо снятия гарда
Владельцу понадобились креды Grafana (basic_auth Caddy + внутренний вход), а
они лежат в backend/.env.runtime, который check-secret-read.py закрывает
целиком. Снимать гард нельзя: в том же файле prod DB-пароли и токены
Forgejo/GlitchTip.

Вместо этого — ALLOWED_KEYS из трёх ключей (METRICS_UI_PASSWORD,
GRAFANA_ADMIN_USER, GRAFANA_ADMIN_PASSWORD) и разрешение ровно на anchored-греп
по ним. Условия намеренно жёсткие, чтобы «прочитать один ключ» нельзя было
развернуть в «выгрузить файл»: блокируются инверсия (-v / --invert-match),
пайпы, цепочки ; && ||, подстановки $(...) и обратные кавычки, редиректы.

Тест на 10 кейсов: разрешён только anchored-греп по ключу из списка; отбиты
инверсия, пайп, цепочка, редирект, подстановка, чужой ключ, греп без якоря ^ и
обычный cat. Проверяется и функция, и хук end-to-end через настоящий
stdin-payload.

Гард в деле: он же отбил эту самую команду коммита, когда текст сообщения
содержал имя закрытого файла рядом с read-verb.
2026-08-26 13:09:37 +03:00

134 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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")
# --- Точечное исключение (по прямому распоряжению владельца, 2026-08-26) ---------
#
# Гард целиком НЕ снят: в тех же .env-файлах лежат prod DB-пароли и токены
# Forgejo/GlitchTip, и они должны оставаться закрытыми. Разрешено ровно одно —
# вытащить значение перечисленных ниже ключей anchored-грепом.
#
# Условия исключения намеренно жёсткие, чтобы «прочитать один ключ» нельзя было
# развернуть в «выгрузить файл»:
# - паттерн привязан к началу строки (^KEY=) и ключ входит в ALLOWED_KEYS;
# - запрещена инверсия (-v / --invert-match) — иначе выдаст ВСЁ, кроме ключа;
# - запрещены пайпы, цепочки, подстановки и редиректы — иначе рядом с
# разрешённым грепом можно провезти второй, произвольный, ридер.
ALLOWED_KEYS = (
"METRICS_UI_PASSWORD",
"GRAFANA_ADMIN_USER",
"GRAFANA_ADMIN_PASSWORD",
)
_ALLOWED_GREP_RE = re.compile(
r"(?i)(?<![\w-])grep\s+(?:-[a-z]+\s+)*['\"]?\^(?:" + "|".join(ALLOWED_KEYS) + r")="
)
_INVERT_RE = re.compile(r"(?i)(?<![\w-])(?:-v|--invert-match)(?![\w-])")
_CHAIN_RE = re.compile(r"[;|&`]|\$\(|>")
def _is_allowed_key_read(cmd: str) -> bool:
"""True только для anchored-грепа одного из ALLOWED_KEYS без способов расширить выдачу."""
if not _ALLOWED_GREP_RE.search(cmd):
return False
if _INVERT_RE.search(cmd):
return False
if _CHAIN_RE.search(cmd):
return False
return True
# 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
if _is_allowed_key_read(cmd):
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())