chore(hooks): точечный allowlist на три ключа метрик вместо снятия гарда #3107

Merged
lekss361 merged 1 commit from chore/hook-allow-metrics-keys into main 2026-08-26 10:18:00 +00:00
2 changed files with 83 additions and 0 deletions

View file

@ -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)(?<![\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-])("
@ -72,6 +108,9 @@ def main() -> 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)

View file

@ -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 "ЕСТЬ ПРОВАЛЫ")