All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
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 / backend-tests (pull_request) Successful in 5m4s
Клиент пишет боту в личку → воркер зеркалит сообщение через copyMessage в топик супергруппы-форума → оператор отвечает реплаем на зеркало → бот доставляет ответ клиенту. Полный лог переписки в Postgres. Отдельный контейнер на long-polling, а не webhook в tradein-backend: не нужно пробивать дырку в auth-middleware (_PUBLIC_PATHS, #2213) и маршрут в Caddy, нулевая внешняя поверхность, падение бота не задевает API. Без aiogram — httpx уже в зависимостях, нужны только getUpdates/copyMessage. Маршрутизация ответа — по topic_message_id: message_id в Telegram уникален в пределах чата сквозь все топики, а все зеркала лежат в одном support-чате, поэтому спутать адресата нельзя. Реплай на шапку/на ответ другого оператора не резолвится (у direction='out' topic_message_id IS NULL) → тихий игнор. Безопасность (найдено ревью, воспроизведено эмпирически): - токен Telegram живёт в PATH URL, поэтому sanitize_url его не режет; утекал в GlitchTip через locals стек-фреймов (include_local_variables по умолчанию True) и через span data HttpxIntegration. Закрыто include_local_variables=False + regex-редактор в before_send (обе формы: /bot<id>:<secret> и голая <id>:<secret>), поверх существующего PII-scrub. - httpx-логгер печатает полный URL на INFO → боевой токен уходил бы в docker logs каждые 30с. Приглушён до WARNING. Надёжность: - kill-switch при пустом токене — idle-блокировка, не exit(0): при restart: unless-stopped выход с любым кодом даёт рестарт-луп. unless-stopped выбран сознательно — только он гарантирует автозапуск после ребута VPS. - stop_grace_period: 120s — дефолтные 10с убивали бы контейнер раньше, чем докрутится long-poll (30с) и отработает drain (100с). - сбой SQL теперь ловится отдельно и делает rollback перед сдвигом offset: иначе сессия в failed-transaction не давала сохранить offset, апдейт переигрывался и зеркалился в топик по кругу. 152-ФЗ: переписка — ПДн, ON DELETE CASCADE по chat_id, удаление клиента одним DELETE. Ретенция — follow-up. Бот не включается автоматически: TELEGRAM_* задаются в runtime-env на VPS, без них воркер штатно висит в idle. Порядок — в DEPLOY.md. Тесты: 51 passed (маршрутизация обоих направлений, дедуп, 403→is_blocked, throttle-окно шапки, redaction токена во всех формах event).
104 lines
6 KiB
Python
104 lines
6 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
|
||
|
||
_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")
|
||
|
||
|
||
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 из error event перед отправкой. Возвращает 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"))
|
||
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]
|