From 8b9b541736727e3c57b9af9cfa4ba546801e49b3 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 18:05:16 +0500 Subject: [PATCH] =?UTF-8?q?alert-ack:=20=D0=B7=D0=BD=D0=B0=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=81=D0=B5=D0=BA=D1=80=D0=B5=D1=82=D0=B0?= =?UTF-8?q?=20=D0=B8=D0=B7=20query=20=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D0=BF=D0=B8=D1=88=D0=B5=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=B2=20=D0=BB=D0=BE=D0=B3=20(#3576)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/tests/ops/test_3078_alert_ack.py | 49 ++++++++++++++++++++++++ ops/metrics/alert-ack/app.py | 15 +++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/backend/tests/ops/test_3078_alert_ack.py b/backend/tests/ops/test_3078_alert_ack.py index 58f3d471..703efddc 100644 --- a/backend/tests/ops/test_3078_alert_ack.py +++ b/backend/tests/ops/test_3078_alert_ack.py @@ -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 diff --git a/ops/metrics/alert-ack/app.py b/ops/metrics/alert-ack/app.py index a809fe6d..823eb112 100644 --- a/ops/metrics/alert-ack/app.py +++ b/ops/metrics/alert-ack/app.py @@ -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) -- 2.45.3