alert-ack: значение секрета вебхука GlitchTip больше не пишется в лог — строка доступа и ошибки разбора дают secret=*** #3588

Merged
bot-backend merged 1 commit from fix/3576-alert-ack-redact-secret into main 2026-09-17 13:15:02 +00:00
2 changed files with 63 additions and 1 deletions

View file

@ -21,7 +21,11 @@
from __future__ import annotations
import importlib.util
import logging
import socket
import sys
import threading
from http.server import ThreadingHTTPServer
from pathlib import Path
from types import ModuleType
@ -179,3 +183,48 @@ def test_protuhshiy_token_ne_prinimaetsya(app: ModuleType, monkeypatch: pytest.M
code, _ = app.do_ack(token)
assert code == 404
assert app.sent == []
# ── Секрет из query не попадает в лог (#3576) ────────────────────────────────
# GlitchTip шлёт секрет резервного вебхука только в `?secret=`, а http.server
# печатает строку запроса целиком — и в строке доступа, и в тексте ошибки
# разбора. Значение ниже выдуманное: проверяется, что его нет ни в одной записи.
_LEAK = "leak-probe-3576-VALUE"
@pytest.mark.parametrize(
("request_line", "masked", "lines_with_mask"),
[
# Строка доступа (log_request), путь резервного вебхука GlitchTip.
(f"POST /glitchtip?secret={_LEAK} HTTP/1.1", "/glitchtip?secret=***", 1),
# Имя с префиксом и соседний параметр — остальная строка цела.
(f"GET /ack/x?a=1&access_token={_LEAK}&b=2 HTTP/1.1", "?a=1&access_token=***&b=2", 1),
# Ошибка разбора (log_error): stdlib кладёт строку запроса в текст ошибки,
# затем та же строка идёт в строку доступа с кодом 400 — обе записи.
(f"POST /glitchtip?secret={_LEAK} junk HTTP/1.1", "/glitchtip?secret=***", 2),
],
)
def test_sekret_iz_query_ne_popadaet_v_log(
app: ModuleType,
caplog: pytest.LogCaptureFixture,
request_line: str,
masked: str,
lines_with_mask: int,
) -> None:
caplog.set_level(logging.INFO, logger="alert-ack")
srv = ThreadingHTTPServer(("127.0.0.1", 0), app.Handler)
threading.Thread(target=srv.serve_forever, daemon=True).start()
try:
with socket.create_connection(srv.server_address, timeout=5) as sock:
sock.sendall(f"{request_line}\r\nHost: x\r\nContent-Length: 0\r\n\r\n".encode())
sock.shutdown(socket.SHUT_WR)
while sock.recv(4096): # до закрытия: к этому моменту запись лога уже сделана
pass
finally:
srv.shutdown()
srv.server_close()
messages = [r.getMessage() for r in caplog.records if r.name == "alert-ack"]
assert not [m for m in messages if _LEAK in m], f"значение секрета в логе: {messages}"
assert sum(masked in m for m in messages) == lines_with_mask, messages

View file

@ -57,6 +57,7 @@ import html
import json
import logging
import os
import re
import secrets
import threading
import time
@ -76,6 +77,16 @@ TTL_SEC = int(os.environ.get("ALERT_ACK_TTL_MIN", "1440")) * 60
GLITCHTIP_SECRET = os.environ.get("ALERT_ACK_GLITCHTIP_SECRET", "")
API = "https://api.telegram.org/bot{}/{}"
# Значения секретных query-параметров в логе — `***` (#3576). GlitchTip шлёт
# секрет только в `?secret=`, а строка запроса целиком уходит в access-log. То
# же выражение, что у бэкенда МЕРЫ (tradein-mvp/backend/app/core/log_scrub.py,
# #3154) и у Alloy (#3354); импортировать нельзя — сервис без зависимостей.
_SENSITIVE_QUERY = re.compile(
r"([?&][\w.-]*(?:secret|token|api[-_]?key|apikey|access[-_]?token|password|signature|sig)=)"
r"[^&\s\"'<>]+",
re.IGNORECASE,
)
# token -> {"message_id": int, "title": str, "created": float, "acked_by": str|None}
_PENDING: dict[str, dict] = {}
_LOCK = threading.Lock()
@ -319,7 +330,9 @@ class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt: str, *args) -> None: # noqa: A003 — подпись из stdlib
log.info("%s %s", self.address_string(), fmt % args)
# Сюда сходятся и строка доступа (log_request), и ошибки разбора запроса
# (log_error: «Bad request syntax ('POST /glitchtip?secret=…')») — маскируем здесь.
log.info("%s %s", self.address_string(), _SENSITIVE_QUERY.sub(r"\1***", fmt % args))
def _reply(self, code: int, body: bytes, ctype: str = "text/html; charset=utf-8") -> None:
self.send_response(code)