Some checks failed
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Failing after 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 46s
CI Trade-In / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 4m42s
Ревью четырьмя независимыми линзами (периметр, семантика Caddy, политика ПДн
против кода, фронт) + по два проверяющих на каждую находку. Ниже — то, что
пережило проверку и воспроизведено на живом коде, а не выведено из чтения.
## Caddy: открытый редирект и потерянные ссылки
Захват хвоста регекспом (`^/trade-in/mera-public/(.+)$` → `redir /{re…1}`) —
открытый редирект. Захват берётся из РАСКОДИРОВАННОГО пути, поэтому
`/trade-in/mera-public/%5Cevil.example/pay` даёт цель `/\evil.example/pay`, а
браузеры трактуют `/\` как `//` — Location уводит на чужой хост. Готовая
фишинговая заготовка с домена, который напечатан внутри оферты и уходит
модератору эквайера. Заменено поимённым списком путей: такой адрес просто не
матчится.
Адреса со слэшем на конце (`/oferta/`, и длинные `…/oferta/`) отдавали 404 —
ровно те ссылки, ради сохранности которых редирект и делался. Добавлена
нормализация, цепочка замкнута (проверено: 2 перехода → 200).
Query-строка терялась: размещённые ссылки с UTM приходили бы в аналитику как
прямой заход. `uri strip_prefix` + `{uri}` переносит её. Обёртка `route`
обязательна — без неё `redir` выполняется раньше `uri` и Location равен
исходному адресу (бесконечный цикл, поймано на стенде).
`/v3` — черновое превью с маркетинговыми плейсхолдерами — было открыто на
боевом домене молча. Теперь названо вслух и запинено тестом.
## Гейты, которых не было
`caddy validate` не звал НИ ОДИН workflow, а deploy применяет конфиг не через
`reload` (тот отказался бы принять битый), а через `up -d --force-recreate` —
опечатка уводит контейнер в crash-loop и роняет ВСЕ домены. Добавлен гейт в
ci.yml, тем же образом caddy:2, что и на проде.
Проверка «роут ↔ Caddy» была односторонней и пропускала обратную ошибку —
путь, открытый наружу, о котором приложение не знает. Так и уехал `/v3`.
Теперь двусторонняя, плюс проверка, что для каждой страницы есть 301.
## Бюджет внешнего геокодера
Per-IP окна ограничивают одного клиента, но не сумму: 40/мин с адреса — это
57 600 в сутки при бесплатном тире DaData в 10 000, ОБЩЕМ с закрытым контуром.
Подтверждено на проде: достаточно упомянуть не-екатеринбургский город, чтобы
локальный тир отключился и запрос гарантированно ушёл во внешний сервис. То
есть один скрипт оставлял без подсказок платящих пилотов.
Per-IP снижен до 20/мин, добавлен общий суточный потолок 2000 и потолок
одновременных подсказок (4): кадастровый тир уходит в FDW-скан чужой базы,
держит соединение около секунды, а пул общий с B2B — полтора десятка
параллельных публичных запросов клали бы закрытый контур.
## «Адрес нигде не сохраняется» — теперь правда целиком
Две утечки, обе воспроизведены:
1. ЖУРНАЛЫ. Геокодер печатает введённую строку открытым текстом на каждый
вызов, прод пишет stdout в persistent journald — адрес ложился на диск
рядом с IP того же запроса в access-логе Caddy. Закрыто фильтром логов на
время публичного запроса (contextvar, переживает await и to_thread).
Закрытый контур логи сохраняет: они нужны для разбора жалоб пилотов.
2. МОНИТОРИНГ. sentry_sdk кладёт в событие ПОЛНОЕ тело запроса — а тело
публичной ручки это ровно `{"q": "<адрес>"}`; `send_default_pii=False` тут
не гейт, он про куки. Плюс брэдкрамб httpx несёт адрес в query геокодера.
Закрыто `scrub_public_address`.
Текст п. 5.4 политики расширен до «ни в журналы веб-сервера, ни в технические
журналы, ни в мониторинг» — ровно то, что теперь обеспечено кодом.
## Фронт
- Отмена запроса подсказок откладывалась внутрь следующего debounce-такта и
не наступала вовсе, если человек переставал печатать: ответ по старой строке
долетал и ложился в список. Контроллер создаётся сразу, отменяется в cleanup.
- Список схлопывался на каждое нажатие — клик по намеченному пункту
промахивался. Старая выдача висит, пока не пришла новая.
- «Комнат» с лэндинга — свободный текст: «студия» не совпадала ни с одним
option, селект показывал пустоту, parseInt давал NaN, на сервер уходил
rooms: null → 422 с текстом «сломалось на нашей стороне». Нормализация
вынесена чистой функцией и покрыта тестами.
- У пробы покрытия не было ни таймаута, ни отмены: оборванное соединение
оставляло кнопку в «Смотрим данные…» навсегда. 15 с + понятный текст.
- Ошибка подсказок глушилась в пустой список — тупик без объяснения.
- Комбобокс: Tab проваливался в кнопки подсказок, список не закрывался по
уходу фокуса и перекрывал поля, Escape оставлял висячий aria-activedescendant.
## Проверено
Локальный стенд (реальный site-блок Caddy + заглушка): 18 маршрутов, включая
`%5C`, `//`, `%2F` — все три теперь 404. vitest 55 passed, backend 17 passed по
публичному API, tsc, lint, build, isolation guard 41 файл, caddy validate.
Мутации: снять редакцию логов → падает тест журналов; не вырезать тело запроса
→ падает тест мониторинга; убрать /estimate из Caddy → падает тест маршрутов.
385 lines
26 KiB
Python
385 lines
26 KiB
Python
"""Хуки 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<id>:<secret>/...`),
|
||
а не в 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<numeric_id>:<secret-part>/<method>.
|
||
# Матчим ровно этот сегмент (не весь 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` — форма `<numeric_id>:<secret>` сама по себе
|
||
# (напр. локаль `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>"
|
||
|
||
|
||
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)
|
||
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[<Future at 0x7f... state=finished raised HTTPStatusError>]`) —
|
||
# 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
|