164 lines
8.3 KiB
Python
164 lines
8.3 KiB
Python
"""Хуки 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
|