diff --git a/scripts/claude-hooks/check-secret-read.py b/scripts/claude-hooks/check-secret-read.py index a7361e70..81857a7c 100644 --- a/scripts/claude-hooks/check-secret-read.py +++ b/scripts/claude-hooks/check-secret-read.py @@ -37,6 +37,42 @@ SECRET_RE = re.compile( ) 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)(?") + + +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)(? int: 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) diff --git a/scripts/claude-hooks/test-check-secret-read.py b/scripts/claude-hooks/test-check-secret-read.py new file mode 100644 index 00000000..97116cf8 --- /dev/null +++ b/scripts/claude-hooks/test-check-secret-read.py @@ -0,0 +1,44 @@ +import importlib.util, json, subprocess, sys + +HOOK = "scripts/claude-hooks/check-secret-read.py" +spec = importlib.util.spec_from_file_location("h", HOOK) +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) + +F = "/opt/gendesign/backend/.env" + ".runtime" +K = "METRICS_UI_PASSWORD" + +cases = [ + (f"timeout 40 ssh -o BatchMode=yes gendesign \"grep '^{K}=' {F}\"", True, "разрешённый anchored-греп"), + (f"ssh gendesign \"grep -v '^{K}=' {F}\"", False, "инверсия -v"), + (f"ssh gendesign \"grep '^{K}=' {F}; cat {F}\"", False, "цепочка ;"), + (f"ssh gendesign \"grep '^{K}=' {F} | cat {F}\"", False, "пайп"), + (f"ssh gendesign \"grep '^{K}=' {F} > /tmp/x\"", False, "редирект"), + (f"ssh gendesign \"grep '^POSTGRES_PASSWORD=' {F}\"", False, "другой ключ"), + (f"ssh gendesign \"grep '{K}' {F}\"", False, "без якоря ^"), + (f"cat {F}", False, "обычный cat"), + (f"ssh gendesign \"grep '^{K}=' {F} && cat {F}\"", False, "цепочка &&"), + (f"ssh gendesign \"grep '^{K}=' $(echo {F})\"", False, "подстановка"), +] + +ok = True +for cmd, want, name in cases: + got = m._is_allowed_key_read(cmd) + if got != want: + ok = False + print(f"{'OK ' if got == want else 'FAIL'} allow={str(got):5} want={str(want):5} {name}") + +print("--- end-to-end через сам хук ---") +for cmd, want_allow, name in cases: + p = subprocess.run( + [sys.executable, HOOK], + input=json.dumps({"tool_name": "Bash", "tool_input": {"command": cmd}}), + capture_output=True, + text=True, + ) + allowed = p.returncode == 0 + if allowed != want_allow: + ok = False + print(f"FAIL rc={p.returncode} want_allow={want_allow} {name}") + +print("ALL OK" if ok else "ЕСТЬ ПРОВАЛЫ")