"""Хуки 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 from tenacity import RetryError _REDACTED = "[REDACTED]" # Ключи consumer-PII (нижний регистр; сверка case-insensitive). # PR-D2 (payments perimeter hardening): + платёжные поля Т-Банка (customer_email/ # customer_phone из checkout, pan/expdate/cardid/rebillid/token/terminalkey из # notify) — belt-and-suspenders поверх `scrub_payment_request_body` ниже, которая # вырезает `request.data` для /payments/* целиком: этот словарь всё равно нужен # для extra/contexts И на случай, если платёжное поле когда-нибудь попадёт в # error event НЕ через request.data (напр. кто-то положит его в extra вручную). _PII_KEYS = frozenset( { "client_name", "client_phone", "client_email", "phone", "email", "name", "customer_email", "customer_phone", "pan", "expdate", "cardid", "rebillid", "token", "terminalkey", } ) # Сегмент пути платёжного периметра (notify + checkout + любой будущий # /payments/* суб-путь) — PR-D2, готовит почву под PR-D3 (эндпоинты ещё не # существуют). Матчим по сегменту, не по конкретному эндпоинту, чтобы не # требовать правки этого файла на каждый новый платёжный путь. _PAYMENTS_URL_SEGMENT = "/api/v1/trade-in/payments/" # 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.rotate_proxy_ip) несла провайдерский API-ключ в query # (`?...&proxy_key=...`). Ручка и переменные удалены (#2616 шаг 2/3, мёртвая # подписка) — редактор оставлен как generic safety net (не ключ-based, любой # будущий query-секрет с распространённым именем параметра тоже покрыт). # Два независимых пути утечки в 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 в traceback # открытым текстом (был прецедент: `rotate_url`/`exc` в удалённом # admin.rotate_proxy_ip). # Как и TG-токен — full-text regex по КАЖДОЙ строке event (не ключ-based): секрет # может всплыть где угодно (frame locals, breadcrumb, exception message). НЕ # завязано на конкретного провайдера — покрывает любой query-параметр из # общеупотребимого набора секретных имён (api_key/proxy_key/token/secret/password/ # access_token/auth) — живой пример: ASOCKS_API_TOKEN (потенциально # другой провайдер, другое имя параметра). _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 # httpx error-message URL query stabilization (GlitchTip-noise review round 2, # claim #1). `httpx.HTTPStatusError.__str__()` (raised by `response.raise_for_status()`) # bakes the FULL request URL — INCLUDING query string — into the exception message: # "Client error '403 Forbidden' for url 'https://nominatim.openstreetmap.org/ # search?q=<адрес>&format=json&limit=3'" (воспроизведено эмпирически: httpx.Response # с params={"q": "<адрес>"} → raise_for_status() → именно этот текст). После # app/services/geocoder.py `reraise=True` (стабилизирует ТИП исключения — RetryError # → httpx.HTTPStatusError, см. комментарий у `_nominatim_lookup`) ИМЕННО этот текст # становится GlitchTip title/value каждого события. `q=<адрес>` — переменная часть # на КАЖДЫЙ вызов (ночной `geocode_missing_listings` — сотни разных адресов за # прогон), значит per-address issue-explosion не устранён `reraise=True`, а просто # переехал с RetryError на HTTPStatusError (тот же механизм: GlitchTip группирует по # нестабильному тексту сообщения — это же подтверждают исходные 2 462 RetryError-issue, # невозможные при группировке чисто по stacktrace/culprit). # # Отдельная регулярка от `_URL_SECRET_QUERY_RE` намеренно: та бьёт по ИМЕНИ известных # secret-параметров (security-редактор), здесь — ЛЮБОЙ query string в httpx-стиле # сообщении "for url '...'" (grouping-стабильность, не секретность — `q` не секрет). # Режем query целиком (не только конкретные параметры) — host+path остаются # стабильными для группировки, "for url '...'" — единственная форма, которую бьёт # regex (не трогает произвольные строки с `?`, см. тест # test_scrub_pii_event_httpx_url_query_stabilization_leaves_unrelated_text_untouched). _HTTPX_ERROR_URL_QUERY_RE = re.compile(r"(for url '[^'?]*)\?[^']*(')") _HTTPX_ERROR_URL_QUERY_REPLACEMENT = r"\g<1>?" + _REDACTED + r"\g<2>" # Capability-токен оплаченного отчёта живёт В ПУТИ (`/api/v1/trade-in/r/`, # app/api/v1/payments.py), а не в query и не в теле — значит ни `_PII_KEYS` # (ключ-based), ни `scrub_payment_request_body` (режет request.data по сегменту # `/payments/`), ни sentry_sdk `sanitize_url` (режет только userinfo и query) # его не касаются. А путь попадает в событие несколькими путями сразу: # `event.request.url`, `transaction`, breadcrumb'ы, текст исключения. Токен — # это ПРАВО ДОСТУПА целиком: утёкший в GlitchTip путь равен выданному отчёту. # Поэтому — full-text regex по всему событию, как у TG-токена. # `[\w-]` покрывает алфавит `secrets.token_urlsafe` (base64url), хвост # `(?=[/?#]|$)` оставляет нетронутым остаток URL (query/фрагмент) — он полезен # для диагностики и секретом не является. _REPORT_LINK_TOKEN_RE = re.compile(r"(/api/v1/trade-in/r/)[\w-]+(?=[/?#]|$)") _REPORT_LINK_TOKEN_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 _regex_redact_inplace(obj: Any, pattern: re.Pattern[str], replacement: str) -> None: """Рекурсивно (IN-PLACE, как `_scrub`) прогоняет `pattern.sub(replacement, ...)` по КАЖДОЙ строке event (не ключ-based) — общий обход, переиспользуемый и для URL-секретов (`_URL_SECRET_QUERY_RE`), и для стабилизации httpx error-message URL (`_HTTPX_ERROR_URL_QUERY_RE`): в обоих случаях переменные данные утекают через 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 = pattern.sub(replacement, value) if redacted != value: obj[key] = redacted else: _regex_redact_inplace(value, pattern, replacement) elif isinstance(obj, list): for i, value in enumerate(obj): if isinstance(value, str): redacted = pattern.sub(replacement, value) if redacted != value: obj[i] = redacted else: _regex_redact_inplace(value, pattern, replacement) # 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 секретов/nondeterministic-данных из 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), (3) full-text regex-проход, стабилизирующий httpx error-message URL (`for url '...?...'`) — убирает переменный query string (адрес геокодинга и т.п.), от которого GlitchTip group-title плодит issue на каждый вызов (GlitchTip-noise review round 2, claim #1; см. комментарий у `_HTTPX_ERROR_URL_QUERY_RE`). (2) и (3) не завязаны на конкретные ключи полей — ловят секрет/переменные данные в 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")) _regex_redact_inplace(event, _URL_SECRET_QUERY_RE, _URL_SECRET_QUERY_REPLACEMENT) _regex_redact_inplace(event, _HTTPX_ERROR_URL_QUERY_RE, _HTTPX_ERROR_URL_QUERY_REPLACEMENT) _regex_redact_inplace(event, _REPORT_LINK_TOKEN_RE, _REPORT_LINK_TOKEN_REPLACEMENT) return event def scrub_payment_request_body(event: Event, _hint: dict[str, Any]) -> Event | None: """Вырезать `event['request']['data']` целиком для платёжных путей (PR-D2). Ключ-based `scrub_pii_event` НЕ спасает платёжную нотификацию: sentry_sdk 2.64 (`integrations/starlette.py`) кладёт ПОЛНОЕ тело запроса в `event.request.data`, и `send_default_pii=False` этот путь не гейтит — тот флаг управляет только куками, не телом запроса (проверено живьём на соседнем продукте). Тело нотификации Т-Банка несёт `Token`/`Pan`/`ExpDate`/`CardId`/ `RebillId`/`DATA` — банк сам выбирает имена полей, перечислить их все заранее нельзя, поэтому единственная безопасная стратегия для этого пути — не отправлять тело целиком, а не пытаться вычистить отдельные ключи. Матчим по сегменту `/api/v1/trade-in/payments/` (не по конкретному эндпоинту) — покрывает notify, checkout и любой будущий суб-путь одним фильтром, без правки этого файла на каждое расширение платёжного API. Сравнение регистронезависимое: `_PUBLIC_PATHS` (rbac) — точное множество без учёта регистра только у Caddy, не у Python, так что нестандартный регистр пути технически может долететь до обработчика и породить событие. Композировать с `scrub_pii_event`/`redact_telegram_bot_token`, а не вместо них — этот шаг закрывает только `request.data`, extra/contexts и traceback-locals остаются на ответственности остальных шагов композиции. """ if not isinstance(event, dict): return event request = event.get("request") if isinstance(request, dict): url = request.get("url") if isinstance(url, str) and _PAYMENTS_URL_SEGMENT in url.lower(): request.pop("data", None) return event _PUBLIC_API_URL_SEGMENT = "/api/public/" #: Хосты геокодеров: их URL несёт введённый адрес прямо в query. _GEOCODER_HOSTS = ("nominatim.openstreetmap.org", "suggestions.dadata.ru", "dadata.ru") _ANY_URL_QUERY_RE = re.compile(r"^([^?]*)\?.*$") def scrub_public_address(event: Event, _hint: dict[str, Any]) -> Event | None: """Убрать введённый анонимом адрес из события GlitchTip. На `meraocenka.ru/estimate` и в политике обработки ПДн сказано, что адрес нигде не сохраняется. GlitchTip — внешний сервис, и до этой правки адрес доезжал туда двумя путями (оба воспроизведены состязательным ревью 16.08.2026, не выведены из чтения кода): 1. `event.request.data`. sentry_sdk кладёт в событие ПОЛНОЕ тело запроса, а `send_default_pii=False` этот путь не гейтит — он про куки и IP, не про тело. Тело публичной ручки — это ровно `{"q": "<адрес>"}`. Ключ-based `scrub_pii_event` не помогает: `_PII_KEYS` перечисляет имена вроде `client_phone`, а поле здесь называется `q`. 2. Брэдкрамб исходящего HTTP-запроса к геокодеру: `HttpxIntegration` кладёт URL целиком, а адрес там в query (`?q=Малышева+30`). Стратегия та же, что у платёжного тела: не вычищать отдельные ключи, а убирать целиком — состав полей задаёт не только наш код (у геокодеров свои параметры), поэтому перечислить безопасное заранее нельзя. Композировать с остальными шагами, а не вместо них. """ if not isinstance(event, dict): return event request = event.get("request") if isinstance(request, dict): url = request.get("url") if isinstance(url, str) and _PUBLIC_API_URL_SEGMENT in url.lower(): request.pop("data", None) crumbs = event.get("breadcrumbs") values = crumbs.get("values") if isinstance(crumbs, dict) else crumbs if isinstance(values, list): for crumb in values: if not isinstance(crumb, dict): continue data = crumb.get("data") if not isinstance(data, dict): continue url = data.get("url") if isinstance(url, str) and any(h in url for h in _GEOCODER_HOSTS): data["url"] = _ANY_URL_QUERY_RE.sub(r"\g<1>?" + _REDACTED, url) 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] # ── RetryError fingerprint stabilization (GlitchTip noise-reduction) ──────── # tenacity.RetryError.__str__() тащит repr() последнего Future # (`RetryError[]`) — # memory address объекта, случайный на каждый вызов процесса. Пока geocoder.py # ретраил Nominatim без `reraise=True`, каждое исчерпание ретраев (Nominatim # недоступен/rate-limit/403) улетало в GlitchTip как RetryError с этим # нестабильным текстом → одна и та же причина плодила отдельный issue на КАЖДОЕ # исчерпание (2 462 issue из 7 461 в трекере на момент фикса). `reraise=True` # в app/services/geocoder.py устраняет RetryError на этом пути (пробрасывает # реальное исключение) — но реальное исключение (httpx.HTTPStatusError) само # несёт нестабильный текст (URL с адресом в query), поэтому group-стабильность # для geocoder держит НЕ эта функция, а `_HTTPX_ERROR_URL_QUERY_RE` в # `scrub_pii_event` (см. её комментарий, GlitchTip-noise review round 2 claim #1). # # Функция ниже — belt-and-suspenders для ЛЮБОГО кода, который ретраит через # tenacity БЕЗ `reraise=True` (живой пример на момент фикса: `BaseScraper._http_get` # в packages/scraper-kit — retry-декоратор НЕ reraise'ит, сознательно оставлен на # этот фолбэк, а не на URL-стабилизацию: ретраятся listing detail URL БЕЗ query # string — переменная часть там в ПУТИ (offer id), которую `_HTTPX_ERROR_URL_QUERY_RE` # не покрывает; см. review round 2 claim #3). Схлопывает RetryError в ОДИН # persistent issue per (culprit, класс исключения-причины) — culprit обязателен: # БЕЗ него RetryError с одинаковым типом причины из НЕСВЯЗАННЫХ подсистем (напр. # geocoder и scraper_kit одновременно ретраят httpx и оба ловят HTTPStatusError) # схлопнулись бы в ОДИН issue — потеря сигнала хуже исходного шума (review round 2 # claim #2). Источник culprit — `event["logger"]`: sentry_sdk `LoggingIntegration` # ставит его в имя logger'а (`logging.getLogger(__name__)`, напр. # "app.services.geocoder" vs "scraper_kit.providers.yandex.detail") на КАЖДОМ # `logger.exception(...)`/`logger.error(...)` — стабильно per-модуль, не зависит от # конкретного запроса. Остальная часть fingerprint собрана ТОЛЬКО из стабильных # данных — имя типа исключения-причины (небольшой фиксированный словарь вроде # "HTTPStatusError"/"ConnectTimeout") — НИКАКИХ переменных данных запроса (адрес, # IP, id объявления и т.п.), иначе проблема повторится в других терминах. def stabilize_retry_error_fingerprint(event: Event, hint: dict[str, Any]) -> Event | None: """before_send-хук: схлопывает tenacity.RetryError в один persistent issue per (источник, тип причины) — РАЗНЫЕ источники (geocoder / scraper_kit / будущий retry-код) НЕ схлопываются друг с другом, даже если тип причины совпадает. Определяет тип exception через `hint["exc_info"]` (реальный объект исключения, тот же контракт что sentry_sdk передаёт в before_send) — не парсит уже сериализованный event dict, надёжнее к изменениям формата SDK. `isinstance` (не сравнение `type(...).__name__` со строкой) — иначе любой посторонний класс с совпадающим именем ложно матчился бы, а подкласс `tenacity.RetryError` — промахивался бы. Не-RetryError события возвращает без изменений (OperationalError, алерты scraper sweep'ов и т.п. фильтр не трогает). """ if not isinstance(event, dict): return event exc_info = hint.get("exc_info") if isinstance(hint, dict) else None exc_value = exc_info[1] if exc_info and len(exc_info) > 1 else None if not isinstance(exc_value, RetryError): return event cause = exc_value.__cause__ or exc_value.__context__ cause_type = type(cause).__name__ if cause is not None else "Unknown" culprit = event.get("logger") or event.get("transaction") or "unknown" event["fingerprint"] = ["retry-exhausted", str(culprit), cause_type] return event