gendesign/tradein-mvp/backend/app/observability/sentry_scrub.py
lekss361 76016fd469
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m52s
Deploy Trade-In / build-backend (push) Successful in 1m43s
Deploy Trade-In / deploy (push) Successful in 1m8s
fix(tradein/security): утечка ключа прокси, аудит действий админа, отличимость неудачного входа, IDOR в заявке (#2536)
2026-07-26 22:42:15 +00:00

172 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Хуки 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
_REDACTED = "[REDACTED]"
# Ключи consumer-PII (нижний регистр; сверка case-insensitive).
_PII_KEYS = frozenset({"client_name", "client_phone", "client_email", "phone", "email", "name"})
# 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.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]