fix(ptica): скраб ПДн перед отправкой в мониторинг + честная подпись НДС в отчётах (#2457)
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m49s
CI / backend-tests (pull_request) Successful in 15m6s
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m49s
CI / backend-tests (pull_request) Successful in 15m6s
Портирован PII-scrub механизм МЕРЫ (scrub_pii_event, ключи client_name/ phone/email/name) в backend/app/observability/sentry_scrub.py и подключен как before_send в app/main.py и app/workers/celery_app.py — раньше worker вообще не скрабил error-события, только transaction-spans (URL-secrets). send_default_pii=False эти поля не закрывает (проверено на sentry-sdk 2.58). full_report_docx.py / full_report_html.py: "НДС (паркинг)" -> "НДС (паркинг + коммерция)" — vat_rub системно включает office_value_added (коммерция), подпись занижала состав суммы (#2457).
This commit is contained in:
parent
9f51c98ff4
commit
d7ccf48000
6 changed files with 218 additions and 10 deletions
|
|
@ -48,7 +48,7 @@ from app.core import auth_db
|
||||||
from app.core.audit_middleware import audit_log_middleware
|
from app.core.audit_middleware import audit_log_middleware
|
||||||
from app.core.auth import get_role
|
from app.core.auth import get_role
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.observability.sentry_scrub import scrub_sensitive_query
|
from app.observability.sentry_scrub import scrub_pii_event, scrub_sensitive_query
|
||||||
from app.services.auth_session import resolve_session_token
|
from app.services.auth_session import resolve_session_token
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -75,6 +75,17 @@ if not any(getattr(_h, "_gd_app_stream", False) for _h in _app_logger.handlers):
|
||||||
# (middleware, маршруты) видели активный client с самого старта процесса.
|
# (middleware, маршруты) видели активный client с самого старта процесса.
|
||||||
# GlitchTip не поддерживает profiling — profiles_sample_rate=0.0.
|
# GlitchTip не поддерживает profiling — profiles_sample_rate=0.0.
|
||||||
if settings.glitchtip_dsn:
|
if settings.glitchtip_dsn:
|
||||||
|
|
||||||
|
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
|
||||||
|
"""Композиция PII-scrub (client_name/phone/email/name из request.data/
|
||||||
|
extra/contexts, аудит-фикс) + URL query-string secrets redact (api-key/
|
||||||
|
token в event["request"]["url"]) — разные классы данных, `send_default_pii=
|
||||||
|
False` ни то ни другое не закрывает (проверено на sentry-sdk 2.58)."""
|
||||||
|
scrubbed = scrub_pii_event(event, hint) # type: ignore[arg-type]
|
||||||
|
if scrubbed is None:
|
||||||
|
return None
|
||||||
|
return scrub_sensitive_query(scrubbed, hint) # type: ignore[arg-type,return-value]
|
||||||
|
|
||||||
sentry_sdk.init(
|
sentry_sdk.init(
|
||||||
dsn=settings.glitchtip_dsn,
|
dsn=settings.glitchtip_dsn,
|
||||||
environment=settings.environment,
|
environment=settings.environment,
|
||||||
|
|
@ -82,7 +93,7 @@ if settings.glitchtip_dsn:
|
||||||
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
||||||
profiles_sample_rate=0.0,
|
profiles_sample_rate=0.0,
|
||||||
send_default_pii=False,
|
send_default_pii=False,
|
||||||
before_send=scrub_sensitive_query,
|
before_send=_before_send,
|
||||||
before_send_transaction=scrub_sensitive_query,
|
before_send_transaction=scrub_sensitive_query,
|
||||||
integrations=[
|
integrations=[
|
||||||
StarletteIntegration(),
|
StarletteIntegration(),
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
"""Хук before_send_transaction для GlitchTip/Sentry SDK.
|
"""Хуки before_send / before_send_transaction для GlitchTip/Sentry SDK.
|
||||||
|
|
||||||
Redact-ит api keys / tokens из URL-spans перед отправкой — чтобы
|
`scrub_sensitive_query` — redact-ит api keys / tokens из URL-spans перед
|
||||||
секреты (apiKey=..., api_key=..., token=...) не утекали в GlitchTip
|
отправкой — чтобы секреты (apiKey=..., api_key=..., token=...) не утекали в
|
||||||
через HttpxIntegration performance-spans.
|
GlitchTip через HttpxIntegration performance-spans.
|
||||||
|
|
||||||
|
`scrub_pii_event` — redact-ит consumer-PII (client_name / phone / email /
|
||||||
|
name) из error events перед отправкой. `send_default_pii=False` в
|
||||||
|
sentry_sdk.init (проверено на sentry-sdk 2.58) НЕ покрывает эти поля — это
|
||||||
|
user-data, попадающий в request.data / extra / contexts (pilot-заявки,
|
||||||
|
лиды, чат), а не PII-заголовки/cookies, которые режет сам флаг. Портировано
|
||||||
|
из trade-in (`tradein-mvp/backend/app/observability/sentry_scrub.py`, #396) —
|
||||||
|
тот же механизм на оба продукта, чтобы сопровождать одинаково.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -12,6 +20,10 @@ from typing import Any
|
||||||
|
|
||||||
from sentry_sdk.types import Event
|
from sentry_sdk.types import Event
|
||||||
|
|
||||||
|
_REDACTED = "[REDACTED]"
|
||||||
|
# Ключи consumer-PII (нижний регистр; сверка case-insensitive).
|
||||||
|
_PII_KEYS = frozenset({"client_name", "phone", "email", "name"})
|
||||||
|
|
||||||
_SENSITIVE_PARAM_RE = re.compile(
|
_SENSITIVE_PARAM_RE = re.compile(
|
||||||
r"((?:api[_-]?[Kk]ey|token|access[_-]?token|secret)=)([^&\s]+)",
|
r"((?:api[_-]?[Kk]ey|token|access[_-]?token|secret)=)([^&\s]+)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
|
|
@ -47,3 +59,34 @@ def scrub_sensitive_query(event: Event, _hint: dict[str, Any]) -> Event | None:
|
||||||
request["url"] = _redact(request["url"])
|
request["url"] = _redact(request["url"])
|
||||||
|
|
||||||
return event
|
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 (client_name / phone / email / name) из error event
|
||||||
|
перед отправкой в 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
|
||||||
|
|
|
||||||
|
|
@ -869,7 +869,7 @@ def _build_financial_cascade(doc: _DocxDocument, financial: dict[str, Any]) -> N
|
||||||
["Земля", _fmt_money_signed(financial.get("land_rub"))],
|
["Земля", _fmt_money_signed(financial.get("land_rub"))],
|
||||||
["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))],
|
["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))],
|
||||||
["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))],
|
["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))],
|
||||||
["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))],
|
["НДС (паркинг + коммерция)", _fmt_money_signed(financial.get("vat_rub"))],
|
||||||
["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))],
|
["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))],
|
||||||
["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))],
|
["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))],
|
||||||
["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))],
|
["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))],
|
||||||
|
|
|
||||||
|
|
@ -1338,7 +1338,7 @@ def _build_financial_cascade(financial: dict[str, Any]) -> str:
|
||||||
["Земля", _fmt_money_signed(financial.get("land_rub"))],
|
["Земля", _fmt_money_signed(financial.get("land_rub"))],
|
||||||
["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))],
|
["Итого затраты", _fmt_money_signed(financial.get("cost_rub"))],
|
||||||
["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))],
|
["Валовая маржа", _fmt_money_signed(financial.get("gross_margin_rub"))],
|
||||||
["НДС (паркинг)", _fmt_money_signed(financial.get("vat_rub"))],
|
["НДС (паркинг + коммерция)", _fmt_money_signed(financial.get("vat_rub"))],
|
||||||
["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))],
|
["Прибыль до налога", _fmt_money_signed(financial.get("profit_before_tax_rub"))],
|
||||||
["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))],
|
["Налог на прибыль", _fmt_money_signed(financial.get("profit_tax_rub"))],
|
||||||
["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))],
|
["Чистая прибыль", _fmt_money_signed(financial.get("net_profit_rub"))],
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration
|
||||||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.observability.sentry_scrub import scrub_sensitive_query
|
from app.observability.sentry_scrub import scrub_pii_event, scrub_sensitive_query
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -23,6 +23,17 @@ logger = logging.getLogger(__name__)
|
||||||
# чтобы события из тасков попадали в GlitchTip. SDK безопасен для двойного
|
# чтобы события из тасков попадали в GlitchTip. SDK безопасен для двойного
|
||||||
# вызова — повторный sentry_sdk.init() в одном процессе заменяет клиента.
|
# вызова — повторный sentry_sdk.init() в одном процессе заменяет клиента.
|
||||||
if settings.glitchtip_dsn:
|
if settings.glitchtip_dsn:
|
||||||
|
|
||||||
|
def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None:
|
||||||
|
"""Композиция PII-scrub (client_name/phone/email/name из request.data/
|
||||||
|
extra/contexts, аудит-фикс) + URL query-string secrets redact — см.
|
||||||
|
app/main.py._before_send (идентичная композиция; до этого фикса worker
|
||||||
|
вообще не скрабил error-события, только transaction-spans)."""
|
||||||
|
scrubbed = scrub_pii_event(event, hint) # type: ignore[arg-type]
|
||||||
|
if scrubbed is None:
|
||||||
|
return None
|
||||||
|
return scrub_sensitive_query(scrubbed, hint) # type: ignore[arg-type,return-value]
|
||||||
|
|
||||||
sentry_sdk.init(
|
sentry_sdk.init(
|
||||||
dsn=settings.glitchtip_dsn,
|
dsn=settings.glitchtip_dsn,
|
||||||
environment=settings.environment,
|
environment=settings.environment,
|
||||||
|
|
@ -30,6 +41,7 @@ if settings.glitchtip_dsn:
|
||||||
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
||||||
profiles_sample_rate=0.0,
|
profiles_sample_rate=0.0,
|
||||||
send_default_pii=False,
|
send_default_pii=False,
|
||||||
|
before_send=_before_send,
|
||||||
before_send_transaction=scrub_sensitive_query,
|
before_send_transaction=scrub_sensitive_query,
|
||||||
integrations=[
|
integrations=[
|
||||||
CeleryIntegration(monitor_beat_tasks=True),
|
CeleryIntegration(monitor_beat_tasks=True),
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
Проверяем что init-блок в main.py / celery_app.py вызывает sentry_sdk.init()
|
Проверяем что init-блок в main.py / celery_app.py вызывает sentry_sdk.init()
|
||||||
только при непустом GLITCHTIP_DSN, что release-fallback работает корректно,
|
только при непустом GLITCHTIP_DSN, что release-fallback работает корректно,
|
||||||
и что scrub_sensitive_query redact-ит api keys из URL spans.
|
что scrub_sensitive_query redact-ит api keys из URL spans, и что
|
||||||
|
scrub_pii_event redact-ит consumer-PII (client_name/phone/email/name) из
|
||||||
|
request.data/extra/contexts перед отправкой в GlitchTip.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
@ -156,3 +158,143 @@ def test_scrub_handles_missing_spans() -> None:
|
||||||
event: dict = {"request": {"url": "https://example.com"}}
|
event: dict = {"request": {"url": "https://example.com"}}
|
||||||
result = scrub_sensitive_query(event, {})
|
result = scrub_sensitive_query(event, {})
|
||||||
assert result["request"]["url"] == "https://example.com"
|
assert result["request"]["url"] == "https://example.com"
|
||||||
|
|
||||||
|
|
||||||
|
# ── scrub_pii_event (портировано из tradein-mvp, #396 / аудит-фикс) ────────────
|
||||||
|
#
|
||||||
|
# send_default_pii=False в sentry_sdk.init НЕ закрывает consumer-PII, попадающий
|
||||||
|
# в request.data / extra / contexts (лиды pilot.py, chat.py) — это user-data,
|
||||||
|
# а не PII-заголовки/cookies, которые режет сам флаг (проверено на sentry-sdk
|
||||||
|
# 2.58). scrub_pii_event закрывает этот вектор ключ-based редактором.
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrub_pii_redacts_request_data() -> None:
|
||||||
|
"""scrub_pii_event заменяет client_name/phone/email в request.data."""
|
||||||
|
from app.observability.sentry_scrub import scrub_pii_event
|
||||||
|
|
||||||
|
event: dict = {
|
||||||
|
"request": {
|
||||||
|
"data": {
|
||||||
|
"client_name": "Иван Иванов",
|
||||||
|
"phone": "+79991234567",
|
||||||
|
"email": "ivan@example.com",
|
||||||
|
"address": "Екатеринбург, ул. Ленина 1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = scrub_pii_event(event, {})
|
||||||
|
data = result["request"]["data"]
|
||||||
|
assert data["client_name"] == "[REDACTED]"
|
||||||
|
assert data["phone"] == "[REDACTED]"
|
||||||
|
assert data["email"] == "[REDACTED]"
|
||||||
|
# non-PII поле не трогаем
|
||||||
|
assert data["address"] == "Екатеринбург, ул. Ленина 1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrub_pii_redacts_extra() -> None:
|
||||||
|
"""scrub_pii_event заменяет PII-ключи в extra, не трогая остальное."""
|
||||||
|
from app.observability.sentry_scrub import scrub_pii_event
|
||||||
|
|
||||||
|
event: dict = {
|
||||||
|
"extra": {
|
||||||
|
"phone": "+79990000000",
|
||||||
|
"email": "x@y.ru",
|
||||||
|
"name": "Пётр",
|
||||||
|
"lead_id": 42,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = scrub_pii_event(event, {})
|
||||||
|
extra = result["extra"]
|
||||||
|
assert extra["phone"] == "[REDACTED]"
|
||||||
|
assert extra["email"] == "[REDACTED]"
|
||||||
|
assert extra["name"] == "[REDACTED]"
|
||||||
|
assert extra["lead_id"] == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrub_pii_is_case_insensitive() -> None:
|
||||||
|
"""Сверка PII-ключей case-insensitive."""
|
||||||
|
from app.observability.sentry_scrub import scrub_pii_event
|
||||||
|
|
||||||
|
event: dict = {"extra": {"Client_Name": "Анна", "PHONE": "+7900"}}
|
||||||
|
result = scrub_pii_event(event, {})
|
||||||
|
assert result["extra"]["Client_Name"] == "[REDACTED]"
|
||||||
|
assert result["extra"]["PHONE"] == "[REDACTED]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrub_pii_redacts_nested_contexts() -> None:
|
||||||
|
"""scrub_pii_event проходит вложенные dict в contexts, не трогая non-PII."""
|
||||||
|
from app.observability.sentry_scrub import scrub_pii_event
|
||||||
|
|
||||||
|
event: dict = {"contexts": {"trace": {"op": "http"}, "lead": {"email": "z@z.ru"}}}
|
||||||
|
result = scrub_pii_event(event, {})
|
||||||
|
assert result["contexts"]["lead"]["email"] == "[REDACTED]"
|
||||||
|
assert result["contexts"]["trace"]["op"] == "http"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrub_pii_leaves_non_pii_untouched() -> None:
|
||||||
|
"""scrub_pii_event не трогает event без PII-ключей."""
|
||||||
|
from app.observability.sentry_scrub import scrub_pii_event
|
||||||
|
|
||||||
|
event: dict = {
|
||||||
|
"request": {"data": {"region": "66", "area_sqm": 50}},
|
||||||
|
"extra": {"job": "geocode"},
|
||||||
|
"level": "error",
|
||||||
|
}
|
||||||
|
result = scrub_pii_event(event, {})
|
||||||
|
assert result["request"]["data"] == {"region": "66", "area_sqm": 50}
|
||||||
|
assert result["extra"] == {"job": "geocode"}
|
||||||
|
assert result["level"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrub_pii_handles_missing_sections() -> None:
|
||||||
|
"""scrub_pii_event не падает на пустом event."""
|
||||||
|
from app.observability.sentry_scrub import scrub_pii_event
|
||||||
|
|
||||||
|
result = scrub_pii_event({}, {})
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrub_pii_handles_none_and_non_dict_sections() -> None:
|
||||||
|
"""scrub_pii_event не бросает исключений на None/не-dict секциях."""
|
||||||
|
from app.observability.sentry_scrub import scrub_pii_event
|
||||||
|
|
||||||
|
event: dict = {"request": None, "extra": None, "contexts": "not-a-dict"}
|
||||||
|
result = scrub_pii_event(event, {})
|
||||||
|
assert result is event
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrub_pii_returns_event_not_none() -> None:
|
||||||
|
"""before_send должен вернуть event (не None) — иначе SDK дропнет отчёт."""
|
||||||
|
from app.observability.sentry_scrub import scrub_pii_event
|
||||||
|
|
||||||
|
event: dict = {"request": {"data": {"client_name": "X"}}}
|
||||||
|
result = scrub_pii_event(event, {})
|
||||||
|
assert result is not None
|
||||||
|
assert result is event
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrub_pii_composes_with_url_secret_scrub() -> None:
|
||||||
|
"""Композиция, реально используемая в app.main._before_send /
|
||||||
|
app.workers.celery_app._before_send: PII-scrub (ключ-based) и URL
|
||||||
|
query-string secret redact (regex) применяются оба, не заменяя друг друга —
|
||||||
|
разные классы данных."""
|
||||||
|
from app.observability.sentry_scrub import scrub_pii_event, scrub_sensitive_query
|
||||||
|
|
||||||
|
event: dict = {
|
||||||
|
"request": {
|
||||||
|
"data": {"client_name": "Иван"},
|
||||||
|
"url": "https://example.com?api_key=supersecret",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def composed_before_send(evt: dict, hint: dict) -> dict | None:
|
||||||
|
scrubbed = scrub_pii_event(evt, hint)
|
||||||
|
if scrubbed is None:
|
||||||
|
return None
|
||||||
|
return scrub_sensitive_query(scrubbed, hint)
|
||||||
|
|
||||||
|
result = composed_before_send(event, {})
|
||||||
|
assert result is not None
|
||||||
|
assert result["request"]["data"]["client_name"] == "[REDACTED]"
|
||||||
|
assert "[REDACTED]" in result["request"]["url"]
|
||||||
|
assert "supersecret" not in result["request"]["url"]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue