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
Владельцу понадобились креды 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.
134 lines
5.4 KiB
Python
134 lines
5.4 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")
|
||
|
||
# --- Точечное исключение (по прямому распоряжению владельца, 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())
|