All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Successful in 3m50s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
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
PR-D2 платёжного контура МЕРЫ — закрывает утечки до открытия публичных путей (PR-D3/D4), сам ничего не открывает: _PUBLIC_PATHS (rbac.py), Caddyfile, roles.yaml, auth_session.py не тронуты. - sentry_scrub.py: новая scrub_payment_request_body — вырезает event.request.data целиком для /api/v1/trade-in/payments/* (sentry_sdk 2.64 кладёт полное тело запроса в request.data, send_default_pii=False это НЕ гейтит — тот флаг управляет только куками). Плюс расширен _PII_KEYS: customer_email/customer_phone/pan/expdate/cardid/rebillid/token/terminalkey. - main.py, scheduler_main.py, tgbot_main.py (все 3 точки инициализации sentry_sdk.init в проекте) — тот же обработчик проведён в ОБА канала, before_send и before_send_transaction. Мотивирующий инцидент: на соседнем продукте вчера закрыли только error-канал, transaction остался без обработчика вообще. - ratelimit.py: точный путь notify — свой щедрый SlidingWindowLimiter (3000/60с per-IP, идиома support.py) вместо общего лимитера, но НЕ полное отключение — backstop против шторма запросов остаётся, подпись проверяется уже после разбора тела (PR-D3). Только notify, не checkout (тот с сессией). - request_audit.py: notify — в audit skip-набор (defense-in-depth: middleware внешний относительно rbac_guard и читает сырой X-Authenticated-User — спуфнутый заголовок иначе писал бы фальшивые события с атрибуцией admin). - smoke-mera-perimeter.sh: негативные проверки-канарейки — notify/checkout сейчас закрыты 404 (meraocenka.ru, Caddy не проксирует) и 401 (gendsgn.ru, rbac ещё не открыл) с обеих сторон периметра. Тесты: scrub на произвольной глубине + payment-path body-wipe, AST-разбор (не substring — комментарии в этих же файлах сами упоминают before_send_transaction) на проводку обоих каналов во всех точках инициализации, 400 запросов notify без единого 429 + контроль что общий лимитер по-прежнему активен на других путях, notify вне user_events даже со спуфнутым X-Authenticated-User: admin.
415 lines
17 KiB
Python
415 lines
17 KiB
Python
"""Unit-тесты consumer-PII scrubber для GlitchTip (#396) + Telegram bot-токен
|
||
redaction (#tgsupport review — CRITICAL: токен утекал в GlitchTip двумя
|
||
независимыми векторами, воспроизведёнными ревьюером на реальном событии
|
||
sentry_sdk: stack-frame locals (`include_local_variables=True`) и httpx-span
|
||
`data` (`HttpxIntegration`). Тесты ниже конструируют event-словари ровно в той
|
||
форме, в которой их реально отдаёт sentry_sdk (frames[].vars, spans[].data),
|
||
чтобы regression на этот конкретный CRITICAL не проскочил молча."""
|
||
|
||
import os
|
||
|
||
import pytest
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.observability.sentry_scrub import (
|
||
redact_telegram_bot_token,
|
||
scrub_payment_request_body,
|
||
scrub_pii_event,
|
||
)
|
||
|
||
|
||
def test_redacts_pii_in_request_data() -> None:
|
||
event = {
|
||
"request": {
|
||
"data": {
|
||
"client_name": "Иван Иванов",
|
||
"client_phone": "+79991234567",
|
||
"client_email": "ivan@example.com",
|
||
"address": "Екатеринбург, ул. Ленина 1",
|
||
}
|
||
}
|
||
}
|
||
out = scrub_pii_event(event, {})
|
||
data = out["request"]["data"]
|
||
assert data["client_name"] == "[REDACTED]"
|
||
assert data["client_phone"] == "[REDACTED]"
|
||
assert data["client_email"] == "[REDACTED]"
|
||
# non-PII поле не трогаем
|
||
assert data["address"] == "Екатеринбург, ул. Ленина 1"
|
||
|
||
|
||
def test_redacts_pii_in_extra() -> None:
|
||
event = {
|
||
"extra": {
|
||
"phone": "+79990000000",
|
||
"email": "x@y.ru",
|
||
"name": "Пётр",
|
||
"estimate_id": 42,
|
||
}
|
||
}
|
||
out = scrub_pii_event(event, {})
|
||
extra = out["extra"]
|
||
assert extra["phone"] == "[REDACTED]"
|
||
assert extra["email"] == "[REDACTED]"
|
||
assert extra["name"] == "[REDACTED]"
|
||
assert extra["estimate_id"] == 42
|
||
|
||
|
||
def test_redact_is_case_insensitive() -> None:
|
||
event = {"extra": {"Client_Name": "Анна", "CLIENT_PHONE": "+7900"}}
|
||
out = scrub_pii_event(event, {})
|
||
assert out["extra"]["Client_Name"] == "[REDACTED]"
|
||
assert out["extra"]["CLIENT_PHONE"] == "[REDACTED]"
|
||
|
||
|
||
def test_redacts_nested_pii_in_contexts() -> None:
|
||
event = {"contexts": {"trace": {"op": "http"}, "consumer": {"client_email": "z@z.ru"}}}
|
||
out = scrub_pii_event(event, {})
|
||
assert out["contexts"]["consumer"]["client_email"] == "[REDACTED]"
|
||
# вложенный non-PII контекст не трогаем
|
||
assert out["contexts"]["trace"]["op"] == "http"
|
||
|
||
|
||
def test_leaves_non_pii_untouched() -> None:
|
||
event = {
|
||
"request": {"data": {"region": "66", "area_sqm": 50}},
|
||
"extra": {"job": "geocode"},
|
||
"level": "error",
|
||
}
|
||
out = scrub_pii_event(event, {})
|
||
assert out["request"]["data"] == {"region": "66", "area_sqm": 50}
|
||
assert out["extra"] == {"job": "geocode"}
|
||
assert out["level"] == "error"
|
||
|
||
|
||
def test_handles_missing_sections() -> None:
|
||
out = scrub_pii_event({}, {})
|
||
assert out == {}
|
||
|
||
|
||
def test_handles_none_and_non_dict_sections() -> None:
|
||
event = {"request": None, "extra": None, "contexts": "not-a-dict"}
|
||
# не должно бросать исключений
|
||
out = scrub_pii_event(event, {})
|
||
assert out is event
|
||
|
||
|
||
def test_returns_event_not_none() -> None:
|
||
"""before_send должен вернуть event (не None) — иначе SDK дропнет отчёт."""
|
||
event = {"request": {"data": {"client_name": "X"}}}
|
||
out = scrub_pii_event(event, {})
|
||
assert out is not None
|
||
assert out is event
|
||
|
||
|
||
# ── Telegram bot-token redaction (#tgsupport review, CRITICAL) ──────────────
|
||
|
||
_LEAKED_TOKEN_URL = "https://api.telegram.org/bot8663867262:AAExampleSecretPartAbCdEf123/getMe"
|
||
|
||
|
||
def test_redacts_token_in_stack_frame_locals() -> None:
|
||
"""Вектор #1 (ревьюер): `include_local_variables=True` кладёт locals
|
||
`TelegramClient._request` (`self`, `url`) в traceback frame `vars`."""
|
||
event = {
|
||
"exception": {
|
||
"values": [
|
||
{
|
||
"type": "NetworkError",
|
||
"stacktrace": {
|
||
"frames": [
|
||
{
|
||
"function": "_request",
|
||
"vars": {
|
||
"url": _LEAKED_TOKEN_URL,
|
||
"self": {"_base": _LEAKED_TOKEN_URL.rsplit("/", 1)[0]},
|
||
"method": "getMe",
|
||
},
|
||
}
|
||
]
|
||
},
|
||
}
|
||
]
|
||
}
|
||
}
|
||
out = redact_telegram_bot_token(event, {})
|
||
assert out is not None
|
||
frame_vars = out["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]
|
||
assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_vars["url"]
|
||
assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_vars["self"]["_base"]
|
||
assert frame_vars["url"] == "https://api.telegram.org/bot[REDACTED]/getMe"
|
||
# Не PII/секрет — остаётся как есть.
|
||
assert frame_vars["method"] == "getMe"
|
||
|
||
|
||
def test_redacts_token_in_httpx_span_data() -> None:
|
||
"""Вектор #2 (ревьюер): `HttpxIntegration` кладёт полный request URL в span
|
||
`data` независимо от traceback — `traces_sample_rate=0.0` спасает сейчас, но
|
||
редактор — belt-and-suspenders на случай если трейсинг когда-нибудь включат."""
|
||
event = {
|
||
"spans": [
|
||
{
|
||
"op": "http.client",
|
||
"description": "POST " + _LEAKED_TOKEN_URL,
|
||
"data": {"url": _LEAKED_TOKEN_URL, "http.method": "POST"},
|
||
}
|
||
]
|
||
}
|
||
out = redact_telegram_bot_token(event, {})
|
||
assert out is not None
|
||
span = out["spans"][0]
|
||
assert "8663867262:AAExampleSecretPartAbCdEf123" not in span["data"]["url"]
|
||
assert "8663867262:AAExampleSecretPartAbCdEf123" not in span["description"]
|
||
assert span["data"]["http.method"] == "POST"
|
||
|
||
|
||
def test_redacts_token_in_breadcrumb_message() -> None:
|
||
"""httpx-логгер (INFO, до нашего getLogger('httpx').setLevel(WARNING) в
|
||
tgbot_main) может всплыть breadcrumb'ом с полным URL через LoggingIntegration."""
|
||
event = {
|
||
"breadcrumbs": {
|
||
"values": [
|
||
{
|
||
"category": "httpx",
|
||
"message": f'HTTP Request: POST {_LEAKED_TOKEN_URL} "HTTP/1.1 401"',
|
||
}
|
||
]
|
||
}
|
||
}
|
||
out = redact_telegram_bot_token(event, {})
|
||
assert out is not None
|
||
msg = out["breadcrumbs"]["values"][0]["message"]
|
||
assert "8663867262:AAExampleSecretPartAbCdEf123" not in msg
|
||
assert "/bot[REDACTED]/getMe" in msg
|
||
|
||
|
||
def test_redact_token_leaves_unrelated_urls_and_strings_untouched() -> None:
|
||
event = {"extra": {"public_url": "https://gendsgn.ru/trade-in", "region": "66"}}
|
||
out = redact_telegram_bot_token(event, {})
|
||
assert out is not None
|
||
assert out["extra"]["public_url"] == "https://gendsgn.ru/trade-in"
|
||
assert out["extra"]["region"] == "66"
|
||
|
||
|
||
def test_redact_token_handles_non_dict_event() -> None:
|
||
assert redact_telegram_bot_token(None, {}) is None # type: ignore[arg-type]
|
||
|
||
|
||
# Голая форма `<id>:<secret>` без префикса `/bot` — так токен выглядит в локали
|
||
# `token` конструктора TelegramClient. Штатно в event не попадает
|
||
# (include_local_variables=False в tgbot_main), но редактор обязан покрывать и
|
||
# этот случай: иначе рубеж против утечки токена всего один — флаг SDK.
|
||
_LEAKED_TOKEN_BARE = "8663867262:AAFakeSecretPartForTestsOnly1234567"
|
||
|
||
|
||
def test_redacts_bare_token_without_bot_prefix() -> None:
|
||
event = {
|
||
"logentry": {"message": f"init failed for {_LEAKED_TOKEN_BARE}"},
|
||
"exception": {
|
||
"values": [{"stacktrace": {"frames": [{"vars": {"token": _LEAKED_TOKEN_BARE}}]}}]
|
||
},
|
||
}
|
||
out = redact_telegram_bot_token(event, {})
|
||
assert out is not None
|
||
assert "AAFakeSecretPartForTestsOnly1234567" not in repr(out)
|
||
frame = out["exception"]["values"][0]["stacktrace"]["frames"][0]
|
||
assert frame["vars"]["token"] == "[REDACTED]"
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"benign",
|
||
[
|
||
"chat_id:12345",
|
||
"ratio 3:4",
|
||
"2026-07-16T10:00:00",
|
||
"postgresql://user:pass@postgres:5432/tradein",
|
||
],
|
||
)
|
||
def test_bare_token_redaction_leaves_benign_colon_strings_untouched(benign: str) -> None:
|
||
"""Голая регулярка не должна бить по любым `x:y` — иначе диагностика ослепнет."""
|
||
out = redact_telegram_bot_token({"logentry": {"message": benign}}, {})
|
||
assert out is not None
|
||
assert out["logentry"]["message"] == benign
|
||
|
||
|
||
# ── Платёжный body-wipe (PR-D2, критерий приёмки #1) ─────────────────────────
|
||
|
||
|
||
def test_scrub_payment_request_body_removes_data_for_payments_path() -> None:
|
||
"""Событие мониторинга с адресом платёжного пути и телом, содержащим `Token`
|
||
и `Pan`, уходит БЕЗ ключа с телом (PR-D2 acceptance criteria)."""
|
||
event = {
|
||
"request": {
|
||
"url": "https://gendsgn.ru/api/v1/trade-in/payments/notify",
|
||
"data": {
|
||
"Token": "deadbeefdeadbeefdeadbeef",
|
||
"Pan": "220000******0000",
|
||
"ExpDate": "1230",
|
||
"CardId": "123456",
|
||
"RebillId": "987654",
|
||
"DATA": {"Email": "someone@example.com"},
|
||
},
|
||
"method": "POST",
|
||
}
|
||
}
|
||
out = scrub_payment_request_body(event, {})
|
||
assert out is not None
|
||
assert "data" not in out["request"]
|
||
# Остальные поля request не тронуты.
|
||
assert out["request"]["method"] == "POST"
|
||
assert out["request"]["url"] == "https://gendsgn.ru/api/v1/trade-in/payments/notify"
|
||
|
||
|
||
def test_scrub_payment_request_body_covers_checkout_too() -> None:
|
||
"""Матч по сегменту пути, не по конкретному эндпоинту — checkout тоже режется."""
|
||
event = {
|
||
"request": {
|
||
"url": "https://gendsgn.ru/api/v1/trade-in/payments/checkout",
|
||
"data": {"consent": True, "product_code": "report_pdf"},
|
||
}
|
||
}
|
||
out = scrub_payment_request_body(event, {})
|
||
assert out is not None
|
||
assert "data" not in out["request"]
|
||
|
||
|
||
def test_scrub_payment_request_body_case_insensitive_url_match() -> None:
|
||
"""Регистр URL не должен позволять данным проскочить — Caddy/rbac регистр
|
||
трактуют по-разному, страховка на случай, если событие всё же породилось."""
|
||
event = {
|
||
"request": {
|
||
"url": "https://gendsgn.ru/API/V1/Trade-In/Payments/Notify",
|
||
"data": {"Token": "secret"},
|
||
}
|
||
}
|
||
out = scrub_payment_request_body(event, {})
|
||
assert out is not None
|
||
assert "data" not in out["request"]
|
||
|
||
|
||
def test_scrub_payment_request_body_leaves_other_paths_untouched() -> None:
|
||
"""Не платёжный путь — тело остаётся (это не общий kill-switch на request.data)."""
|
||
event = {
|
||
"request": {
|
||
"url": "https://gendsgn.ru/api/v1/trade-in/estimate",
|
||
"data": {"area_sqm": 50, "region": "66"},
|
||
}
|
||
}
|
||
out = scrub_payment_request_body(event, {})
|
||
assert out is not None
|
||
assert out["request"]["data"] == {"area_sqm": 50, "region": "66"}
|
||
|
||
|
||
def test_scrub_payment_request_body_handles_missing_request() -> None:
|
||
out = scrub_payment_request_body({"level": "error"}, {})
|
||
assert out == {"level": "error"}
|
||
|
||
|
||
def test_scrub_payment_request_body_handles_non_dict_event() -> None:
|
||
assert scrub_payment_request_body(None, {}) is None # type: ignore[arg-type]
|
||
|
||
|
||
def test_scrub_payment_request_body_handles_missing_url() -> None:
|
||
"""`request` без `url` (нестандартный event) — не бросает, тело не трогает."""
|
||
event = {"request": {"data": {"Token": "x"}}}
|
||
out = scrub_payment_request_body(event, {})
|
||
assert out is not None
|
||
assert out["request"]["data"] == {"Token": "x"}
|
||
|
||
|
||
# ── Расширенный набор платёжных PII-ключей (PR-D2, критерий приёмки #2) ──────
|
||
|
||
|
||
def test_pii_keys_scrub_payment_fields_at_arbitrary_depth() -> None:
|
||
"""Скрабер вычищает `customer_email`/`customer_phone`/платёжные поля на
|
||
произвольной глубине вложенности (PR-D2 acceptance criteria)."""
|
||
event = {
|
||
"extra": {
|
||
"checkout_context": {
|
||
"buyer": {
|
||
"customer_email": "buyer@example.com",
|
||
"customer_phone": "+79991234567",
|
||
"nested_list": [
|
||
{"pan": "220000******1111", "expdate": "0129"},
|
||
{"cardid": "abc123", "rebillid": "xyz789"},
|
||
],
|
||
},
|
||
"token": "sensitive-token-value",
|
||
"terminalkey": "TinkoffBankTest",
|
||
"order_id": "ord_123",
|
||
}
|
||
}
|
||
}
|
||
out = scrub_pii_event(event, {})
|
||
ctx = out["extra"]["checkout_context"]
|
||
assert ctx["buyer"]["customer_email"] == "[REDACTED]"
|
||
assert ctx["buyer"]["customer_phone"] == "[REDACTED]"
|
||
assert ctx["buyer"]["nested_list"][0]["pan"] == "[REDACTED]"
|
||
assert ctx["buyer"]["nested_list"][0]["expdate"] == "[REDACTED]"
|
||
assert ctx["buyer"]["nested_list"][1]["cardid"] == "[REDACTED]"
|
||
assert ctx["buyer"]["nested_list"][1]["rebillid"] == "[REDACTED]"
|
||
assert ctx["token"] == "[REDACTED]"
|
||
assert ctx["terminalkey"] == "[REDACTED]"
|
||
# non-PII поле остаётся.
|
||
assert ctx["order_id"] == "ord_123"
|
||
|
||
|
||
def test_composed_before_send_scrubs_pii_and_token_together() -> None:
|
||
"""Композиция, реально используемая в `app.tgbot_main._before_send`: PII-scrub
|
||
(ключ-based) И token-redaction (regex full-text) применяются оба, не заменяя
|
||
друг друга — разные классы секретов, разные механизмы обнаружения."""
|
||
event = {
|
||
"request": {"data": {"client_phone": "+79991234567"}},
|
||
"exception": {
|
||
"values": [
|
||
{
|
||
"stacktrace": {
|
||
"frames": [{"vars": {"url": _LEAKED_TOKEN_URL}}],
|
||
}
|
||
}
|
||
]
|
||
},
|
||
}
|
||
|
||
def composed_before_send(evt, hint):
|
||
scrubbed = scrub_pii_event(evt, hint)
|
||
if scrubbed is None:
|
||
return None
|
||
return redact_telegram_bot_token(scrubbed, hint)
|
||
|
||
out = composed_before_send(event, {})
|
||
assert out is not None
|
||
assert out["request"]["data"]["client_phone"] == "[REDACTED]"
|
||
frame_url = out["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]["url"]
|
||
assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_url
|
||
|
||
|
||
def test_composed_before_send_payment_wipe_pii_and_token_together() -> None:
|
||
"""Полная композиция `app.main._before_send` (PR-D2): body-wipe для платёжного
|
||
пути → PII-scrub → token-redaction, в этом порядке, все три применяются."""
|
||
event = {
|
||
"request": {
|
||
"url": "https://gendsgn.ru/api/v1/trade-in/payments/notify",
|
||
"data": {"Token": "deadbeef", "Pan": "220000******0000"},
|
||
},
|
||
"extra": {"client_phone": "+79991234567"},
|
||
"exception": {
|
||
"values": [{"stacktrace": {"frames": [{"vars": {"url": _LEAKED_TOKEN_URL}}]}}]
|
||
},
|
||
}
|
||
|
||
def composed_before_send(evt, hint):
|
||
scrubbed = scrub_payment_request_body(evt, hint)
|
||
if scrubbed is None:
|
||
return None
|
||
scrubbed = scrub_pii_event(scrubbed, hint)
|
||
if scrubbed is None:
|
||
return None
|
||
return redact_telegram_bot_token(scrubbed, hint)
|
||
|
||
out = composed_before_send(event, {})
|
||
assert out is not None
|
||
assert "data" not in out["request"]
|
||
assert out["extra"]["client_phone"] == "[REDACTED]"
|
||
frame_url = out["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]["url"]
|
||
assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_url
|