"""Хуки before_send для GlitchTip/Sentry SDK (tradein-local, #396, #tgsupport). Redact-ит consumer-PII (client_name / client_phone / client_email и пр.) из error events до отправки в GlitchTip — estimator/trade-in flow таскает эти поля, а send_default_pii=False их не покрывает (это user-data в request.data / extra / contexts, не PII-заголовки). `redact_telegram_bot_token` — отдельный хук (#tgsupport review): Telegram Bot API токен живёт в URL-пути (`https://api.telegram.org/bot:/...`), а не в query/userinfo, поэтому НЕ покрывается sentry_sdk `sanitize_url` (тот режет только `user:pass@` и query-параметры). Токен утекает ДВУМЯ путями, которые `_scrub`/`scrub_pii_event` (ключ-based, PII-словарь) не ловят: 1. `include_local_variables=True` (sentry_sdk default) кладёт locals stack-фрейма (`self._base`, `url` в `TelegramClient._request`) в traceback → полный токен открытым текстом. 2. `HttpxIntegration` кладёт полный request URL в span `data` (виден при любом ненулевом `traces_sample_rate`), а не только в traceback. Поэтому редактор — НЕ ключ-based, а regex full-text по КАЖДОЙ строке во всём event (глубокий обход dict/list/tuple) — токен может всплыть в любом поле. """ from __future__ import annotations import re from typing import Any from sentry_sdk.types import Event _REDACTED = "[REDACTED]" # Ключи consumer-PII (нижний регистр; сверка case-insensitive). _PII_KEYS = frozenset({"client_name", "client_phone", "client_email", "phone", "email", "name"}) # Telegram Bot API токен в пути URL: /bot:/. # Матчим ровно этот сегмент (не весь URL) — сохраняет остальной путь/query # читаемым для диагностики (метод API, error code и т.п.). _TG_BOT_TOKEN_RE = re.compile(r"/bot\d+:[A-Za-z0-9_-]+") _TG_BOT_TOKEN_REPLACEMENT = "/bot[REDACTED]" # Тот же токен БЕЗ префикса `/bot` — форма `:` сама по себе # (напр. локаль `token` в конструкторе TelegramClient, или если его кто-то # засунет в log-сообщение). Сейчас единственный путь такой формы в event — # locals стек-фрейма, а они выключены через include_local_variables=False в # tgbot_main. Но именно на отказ того флага этот редактор и страхует: без этой # ветки рубеж был бы один, а не два. Формат токена BotFather: 8-12 цифр `:` 35 # символов base64url — нижние границы взяты с запасом, чтобы не промахнуться # на нестандартных id, но остаться уже, чем `\d+:\S+` (тот бил бы по любым # `id:value` в логах, напр. `chat_id:12345`). _TG_BOT_TOKEN_BARE_RE = re.compile(r"\b\d{6,12}:[A-Za-z0-9_-]{30,}\b") # Query-string секреты в исходящих URL сторонних API (аудит-фикс, #security-audit): # mobileproxy changeip-ссылка (`AVITO_PROXY_ROTATE_URL` и др., admin.py # rotate_proxy_ip) несёт провайдерский API-ключ в query (`?...&proxy_key=...`). # Два независимых пути утечки в GlitchTip, зеркалящих TG-токен выше: # 1. `HttpxIntegration.send()` парсит URL через `parse_url(str(request.url), # sanitize=False)` (ЯВНЫЙ opt-out из sentry_sdk `sanitize_url`, который иначе # сам вырезал бы query-параметры) и кладёт полный URL в span `data["url"]` — # сейчас неактивно (`traces_sample_rate=0.0` в app/main.py/scheduler_main.py → # span не сэмплится/не уходит), но молча перестанет спасать, если трейсинг # когда-нибудь включат. # 2. `include_local_variables=True` (sentry_sdk default в app/main.py — в отличие # от tgbot_main.py, где явно False) кладёт stack-frame locals (`rotate_url`, # `exc` в rotate_proxy_ip) в traceback открытым текстом. # Как и TG-токен — full-text regex по КАЖДОЙ строке event (не ключ-based): секрет # может всплыть где угодно (frame locals, breadcrumb, exception message). НЕ # завязано на конкретного провайдера — покрывает любой query-параметр из # общеупотребимого набора секретных имён (api_key/proxy_key/token/secret/password/ # access_token/auth), т.к. cian/yandex у нас имеют СВОИ rotate-URL (потенциально # другой провайдер, другое имя параметра). _URL_SECRET_QUERY_RE = re.compile( r"(?i)([?&](?:api[_-]?key|proxy[_-]?key|token|secret|password|pwd|" r"access[_-]?token|auth)=)[^&\s\"'<>]+" ) _URL_SECRET_QUERY_REPLACEMENT = r"\g<1>" + _REDACTED def _scrub(obj: Any) -> None: """Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place).""" if isinstance(obj, dict): for key, value in obj.items(): if isinstance(key, str) and key.lower() in _PII_KEYS: obj[key] = _REDACTED else: _scrub(value) elif isinstance(obj, list): for item in obj: _scrub(item) def _redact_url_secrets_inplace(obj: Any) -> None: """Рекурсивно (IN-PLACE, как `_scrub`) заменяет значения секрет-подобных query-параметров (`?token=...`, `?proxy_key=...` и т.п.) на [REDACTED] в КАЖДОЙ строке event — не ключ-based: секрет утекает через httpx span `url`/`query` data и через текст исключений (`str(exc)` httpx содержит полный request URL), а не только через известные PII-поля формы. Мутирует dict/list на месте (НЕ пересоздаёт структуру, в отличие от `_redact_strings`) — сохраняет identity верхнеуровневого `event`, на что опирается контракт `scrub_pii_event`/`before_send` и существующие тесты (`out is event`). """ if isinstance(obj, dict): for key, value in obj.items(): if isinstance(value, str): redacted = _URL_SECRET_QUERY_RE.sub(_URL_SECRET_QUERY_REPLACEMENT, value) if redacted != value: obj[key] = redacted else: _redact_url_secrets_inplace(value) elif isinstance(obj, list): for i, value in enumerate(obj): if isinstance(value, str): redacted = _URL_SECRET_QUERY_RE.sub(_URL_SECRET_QUERY_REPLACEMENT, value) if redacted != value: obj[i] = redacted else: _redact_url_secrets_inplace(value) # tuple намеренно не обрабатываем: sentry_sdk event — это JSON-совместимая # структура (dict/list/str/int/...), tuple там не встречается, а даже если бы # встретился — он immutable, in-place правка невозможна (см. `_scrub`, тот же # выбор для dict/list). def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None: """Redact consumer-PII + URL query-string секретов из error event перед отправкой. Композиция (обе — in-place, сохраняют identity `event`): (1) ключ-based dict-scrub consumer-PII полей формы (как раньше), (2) full-text regex-проход по ВСЕМУ event, вырезающий значения секрет-подобных query-параметров в любой строке (proxy/API-ключи в исходящих URL сторонних сервисов, напр. mobileproxy changeip — #security-audit). Второй шаг не завязан на конкретные ключи полей — ловит секрет в frame locals, breadcrumb, exception message и т.д., где он может оказаться независимо от include_local_variables/traces_sample_rate. Возвращает event (не None). """ if not isinstance(event, dict): return event request = event.get("request") if isinstance(request, dict): _scrub(request.get("data")) _scrub(event.get("extra")) _scrub(event.get("contexts")) _redact_url_secrets_inplace(event) return event def _redact_strings(obj: Any) -> Any: """Рекурсивно проходит dict/list/tuple и прогоняет обе токен-регулярки по КАЖДОЙ строке (не только по конкретным ключам) — токен может оказаться в locals stack-фрейма, span data, breadcrumb message, request.url и т.д. Возвращает НОВУЮ структуру (не мутирует `obj` — в отличие от `_scrub`, чтобы не зависеть от того, какие контейнеры sentry_sdk считает mutable в своём event dict).""" if isinstance(obj, str): redacted = _TG_BOT_TOKEN_RE.sub(_TG_BOT_TOKEN_REPLACEMENT, obj) return _TG_BOT_TOKEN_BARE_RE.sub(_REDACTED, redacted) if isinstance(obj, dict): return {k: _redact_strings(v) for k, v in obj.items()} if isinstance(obj, list): return [_redact_strings(v) for v in obj] if isinstance(obj, tuple): return tuple(_redact_strings(v) for v in obj) return obj def redact_telegram_bot_token(event: Event, _hint: dict[str, Any]) -> Event | None: """Full-text regex redaction Telegram Bot API токена по ВСЕМУ event (#tgsupport). Ловит оба вектора утечки токена в GlitchTip, которые ключ-based `scrub_pii_event` не покрывает: locals stack-фреймов (`include_local_variables=True`) и httpx-span `data` (полный request URL). Композировать с `scrub_pii_event`, не вместо него — разные классы секретов (PII полей формы vs bot-токен в URL). """ if not isinstance(event, dict): return event return _redact_strings(event) # type: ignore[return-value]