alert-ack: значение секрета из query больше не пишется в лог (#3576)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 19s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 23s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m57s
CI / backend-tests (pull_request) Successful in 7m35s

GlitchTip шлёт секрет резервного вебхука только в `?secret=`, а
BaseHTTPRequestHandler печатает строку запроса целиком: в строке доступа
(log_request) и в тексте ошибки разбора (log_error, «Bad request syntax
('POST /glitchtip?secret=…')»). Оба пути сходятся в log_message — маскируем
там одним выражением, тем же, что у бэкенда МЕРЫ (#3154, log_scrub.py) и у
Alloy (#3354). Импортировать его нельзя: сервис намеренно без зависимостей.

Тест в backend/tests/ops/test_3078_alert_ack.py (его гоняет CI по ops/**):
настоящий сокет, три строки запроса — доступ, имя с префиксом, ошибка
разбора; значения в записях нет, `=***` стоит в ожидаемом числе записей.

Ротация секрета — за владельцем, здесь не делается.

Refs #3576

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-09-17 18:05:16 +05:00
parent a1c7f8ea92
commit 8b9b541736
2 changed files with 63 additions and 1 deletions

View file

@ -21,7 +21,11 @@
from __future__ import annotations from __future__ import annotations
import importlib.util import importlib.util
import logging
import socket
import sys import sys
import threading
from http.server import ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from types import ModuleType from types import ModuleType
@ -179,3 +183,48 @@ def test_protuhshiy_token_ne_prinimaetsya(app: ModuleType, monkeypatch: pytest.M
code, _ = app.do_ack(token) code, _ = app.do_ack(token)
assert code == 404 assert code == 404
assert app.sent == [] 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 json
import logging import logging
import os import os
import re
import secrets import secrets
import threading import threading
import time 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", "") GLITCHTIP_SECRET = os.environ.get("ALERT_ACK_GLITCHTIP_SECRET", "")
API = "https://api.telegram.org/bot{}/{}" 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} # token -> {"message_id": int, "title": str, "created": float, "acked_by": str|None}
_PENDING: dict[str, dict] = {} _PENDING: dict[str, dict] = {}
_LOCK = threading.Lock() _LOCK = threading.Lock()
@ -319,7 +330,9 @@ class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" protocol_version = "HTTP/1.1"
def log_message(self, fmt: str, *args) -> None: # noqa: A003 — подпись из stdlib 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: def _reply(self, code: int, body: bytes, ctype: str = "text/html; charset=utf-8") -> None:
self.send_response(code) self.send_response(code)