gendesign/backend/app/observability/sentry_scrub.py
bot-backend fb7e94ee65 fix(ptica/observability): страховка скраба перестаёт утекать то, что защищает (#2753)
Три дыры в скрабе ПДн перед отправкой в мониторинг, и все три проверялись
поиском подстроки в исходнике — гейтом, который зелен на сломанной проводке.

1. include_local_variables=False в обеих точках входа (main.py, celery_app.py).
   Дефолт SDK — True, Птица его нигде не переопределяла: при ЛЮБОМ исключении
   кадр стека нёс значения аргументов (телефон заявки, адрес, токен) под
   произвольными именами. Скраб сверяет ИМЕНА ключей — такое он не ловит по
   построению, то есть это не дополнительная мера, а условие его полноты.
   У МЕРЫ флаг стоит с #2737.

2. Сбой самого скраба больше не уходит в мониторинг: ignore_logger на модуль +
   логирование без трассировки и без str(exc). До этого logger.exception внутри
   before_send создавал НОВОЕ событие, в локальных переменных которого лежал
   неочищенный event целиком, и это событие снова падало в тот же обработчик.
   Проверено исполнением: рекурсия не завершается, 1000+ вложенных трассировок
   за минуту. Диагностика осталась в stdout — текст трассировки значений
   переменных не печатает.

3. Проводка проверяется ПОВЕДЕНИЕМ, а не текстом файла. tests/_sentry_wiring_
   probe.py поднимает настоящий sentry_sdk.init() в подпроцессе, подменяет
   транспорт и смотрит, что до него доехало: тело запроса, транзакция, кадр
   стека, повторный вход при сбое скраба. Наружу не уходит ничего — DSN на
   несуществующий хост, capture_envelope подменён до первого события, маркеры
   случайные.

Старый гейт зелен на разорванной проводке: удалить before_send=scrub_event и
переформулировать соседний комментарий, назвав в нём тот же аргумент, — 2 passed.
Новый на том же коде — 2 failed. На коде до этого коммита новые проверки красные
(локальные переменные ушли в транспорт; сбой скраба вошёл в обработчик 4 раза).
2026-08-07 14:45:00 +05:00

164 lines
8.3 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 / before_send_transaction для GlitchTip/Sentry SDK.
`scrub_sensitive_query` — redact-ит api keys / tokens из URL-spans перед
отправкой — чтобы секреты (apiKey=..., api_key=..., token=...) не утекали в
GlitchTip через HttpxIntegration performance-spans.
`scrub_pii_event` — redact-ит consumer-PII (client_name / client_phone /
client_email / phone / email / name / company / message) из events перед
отправкой. `send_default_pii=False` в sentry_sdk.init (проверено на
sentry-sdk 2.58) НЕ покрывает эти поля — это user-data, попадающий в
request.data / extra / contexts (pilot-заявки — `PilotRequestInput` в
`app/api/v1/pilot.py` несёт все 6 полей включая свободный текст `company`/
`message`, куда чаще всего прилетают телефоны/имена/адреса; чат — свободный
вопрос в `app/schemas/chat.py`), а не PII-заголовки/cookies, которые режет
сам флаг. Портировано из trade-in (`tradein-mvp/backend/app/observability/
sentry_scrub.py`, #396) — тот же набор ключей (client_name/client_phone/
client_email — Птица их не использует сегодня, но одинаковый механизм на
оба продукта проще сопровождать), плюс `company`/`message`, специфичные для
`PilotRequestInput` (#2457-review).
`scrub_event` — composed-хендлер (PII-scrub + URL-secret redact), которым
надо вешать ОБА канала — `before_send` И `before_send_transaction`.
Starlette-интеграция кладёт тело запроса в `request_info["data"]` на
transaction-scope точно так же, как на error-scope (scope-обработчики для
transactions НЕ пропускаются — пропуск бывает только на availability-чеках).
Если повесить PII-scrub только на `before_send`, а `before_send_transaction`
оставить на голом `scrub_sensitive_query` — PII продолжит течь через
transaction-канал при любом `glitchtip_traces_sample_rate > 0` (#2457-review,
воспроизведено: pilot-заявка с реальными данными → ~1/20 попадает в
транзакцию с полным телом).
"""
from __future__ import annotations
import logging
import re
from typing import Any
from sentry_sdk.integrations.logging import ignore_logger
from sentry_sdk.types import Event
logger = logging.getLogger(__name__)
# Собственный сбой скраба НЕ должен становиться событием мониторинга (#2753).
# LoggingIntegration (event_level=ERROR) превратила бы строку журнала об отказе
# в новое событие, которое снова пойдёт через этот же обработчик; при
# детерминированном сбое это рекурсия — защиты от неё в SDK нет (проверено:
# 1000+ вложенных трассировок за минуту, процесс не завершается). Диагностика
# остаётся в stdout контейнера: текст трассировки значений переменных не несёт.
ignore_logger(__name__)
_REDACTED = "[REDACTED]"
# Ключи consumer-PII (нижний регистр; сверка case-insensitive). Набор МЕРЫ
# (client_name/client_phone/client_email/phone/email/name, #396) + company/
# message — специфичные для PilotRequestInput (app/api/v1/pilot.py) поля
# свободного текста (#2457-review).
_PII_KEYS = frozenset(
{
"client_name",
"client_phone",
"client_email",
"phone",
"email",
"name",
"company",
"message",
}
)
_SENSITIVE_PARAM_RE = re.compile(
r"((?:api[_-]?[Kk]ey|token|access[_-]?token|secret)=)([^&\s]+)",
re.IGNORECASE,
)
def _redact(value: Any) -> Any:
"""Заменить значения чувствительных query-параметров на [REDACTED]."""
if isinstance(value, str):
return _SENSITIVE_PARAM_RE.sub(r"\1[REDACTED]", value)
return value
def scrub_sensitive_query(event: Event, _hint: dict[str, Any]) -> Event | None:
"""Redact api keys / tokens из URL spans перед отправкой в GlitchTip.
Обрабатывает:
- span.data["url"], span.data["http.url"], span.data["http.target"]
- span["description"]
- event["request"]["url"]
"""
for span in event.get("spans") or []:
data = span.get("data")
if isinstance(data, dict):
for key in ("url", "http.url", "http.target"):
if key in data:
data[key] = _redact(data[key])
if "description" in span:
span["description"] = _redact(span["description"])
request = event.get("request")
if isinstance(request, dict) and "url" in request:
request["url"] = _redact(request["url"])
return event
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 scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None:
"""Redact consumer-PII (см. `_PII_KEYS`) из event (error ИЛИ transaction)
перед отправкой в GlitchTip.
Обходит `request.data` / `extra` / `contexts` рекурсивно (dict/list),
заменяет значения PII-ключей на [REDACTED] in-place. Возвращает event
(не None) — иначе SDK дропнет отчёт целиком.
"""
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"))
return event
def scrub_event(event: Event, hint: dict[str, Any]) -> Event | None:
"""Composed `before_send` / `before_send_transaction` handler: PII-scrub +
URL query-secret redact. Вешать ОДИНАКОВО на оба канала — см. module
docstring (#2457-review): transaction-scope несёт `request.data` точно так
же, как error-scope.
try/except — предохранитель: sentry_sdk оборачивает вызов `before_send` в
`capture_internal_exceptions`, который при исключении внутри хендлера
ТОЛЬКО логирует и ДРОПАЕТ event целиком (SDK никогда не узнает, что
редактор упал, — event просто не уйдёт). Наблюдаемость важнее полноты
покрытия редактора: лучше отправить событие в состоянии "сколько успели
отредактировать до сбоя", чем не отправить вообще и молча остаться без
сигнала в мониторинге.
"""
try:
scrub_pii_event(event, hint)
scrub_sensitive_query(event, hint)
except Exception as exc:
# Ни трассировки, ни str(exc): и то и другое способно нести значения из
# ЕЩЁ НЕ ОЧИЩЕННОГО event — то есть страховка утекла бы ровно то, что
# защищает (#2753). Имя класса исключения данных не несёт. Событием
# мониторинга эта строка не станет — см. ignore_logger выше.
logger.error(
"sentry_scrub.scrub_event: handler failed (%s), sending event as-is",
type(exc).__name__,
)
return event