From 8a04af5da3d8ee3fb6eef6620d0aca16aa72748b Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 16 May 2026 18:04:01 +0300 Subject: [PATCH] fix(backend): GlitchTip release fallback + apiKey scrub (#204 review fixes) - release= now: GIT_SHA or SENTRY_RELEASE or "unknown" (covers existing deploy.yml which writes SENTRY_RELEASE=$IMAGE_TAG, not GIT_SHA) - new module app/observability/sentry_scrub.py: before_send_transaction hook that redacts apiKey/token/access_token/secret query params from HttpxIntegration spans before they reach GlitchTip - config.py: model_validator promotes legacy SENTRY_DSN -> glitchtip_dsn with DeprecationWarning for backward compat on existing VPS env - .env.example: added deprecated SENTRY_DSN entry with comment - tests: 8 new cases covering release fallback + scrub module (11 total) --- backend/.env.example | 6 +- backend/app/core/config.py | 24 +++++ backend/app/main.py | 4 +- backend/app/observability/__init__.py | 0 backend/app/observability/sentry_scrub.py | 49 ++++++++++ backend/app/workers/celery_app.py | 4 +- backend/tests/test_sentry_init.py | 105 +++++++++++++++++++++- 7 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 backend/app/observability/__init__.py create mode 100644 backend/app/observability/sentry_scrub.py diff --git a/backend/.env.example b/backend/.env.example index adc92d38..32bd1d6e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,11 +6,13 @@ REDIS_URL=redis://redis:6379/0 CORS_ORIGINS=["http://localhost:3000"] ENVIRONMENT=dev -# GlitchTip error tracking (https://errors.gendsgn.ru) +# DSN для GlitchTip (https://errors.gendsgn.ru). Пустое = SDK no-op. # Формат: https://@errors.gendsgn.ru/ -# Пустая строка = SDK не инициализируется. GLITCHTIP_DSN= GLITCHTIP_TRACES_SAMPLE_RATE=0.05 +# DEPRECATED — будет удалён после 1-2 deploy-циклов. Использовать GLITCHTIP_DSN. +# Если задан, а GLITCHTIP_DSN пуст — автоматически продвигается с DeprecationWarning. +SENTRY_DSN= # External APIs (Stage 2) ROSREESTR_PKK_BASE_URL=https://pkk.rosreestr.ru/api/features/1 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index c71418bd..16c29b34 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,3 +1,7 @@ +import os +import warnings + +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -14,6 +18,26 @@ class Settings(BaseSettings): glitchtip_traces_sample_rate: float = 0.05 environment: str = "dev" + @model_validator(mode="after") + def _promote_legacy_sentry_dsn(self) -> "Settings": + """Backward-compat: если SENTRY_DSN задан, а GLITCHTIP_DSN пуст — автопродвигаем. + + На существующем VPS .env.runtime содержит SENTRY_DSN=https://... + (старое имя переменной). Pydantic extra="ignore" молча его отбрасывает, + SDK остаётся no-op. Читаем напрямую из os.environ и выдаём DeprecationWarning. + """ + if not self.glitchtip_dsn: + legacy = os.getenv("SENTRY_DSN") + if legacy: + warnings.warn( + "SENTRY_DSN is set but ignored by pydantic; rename to GLITCHTIP_DSN. " + "Auto-promoting for backward compat.", + DeprecationWarning, + stacklevel=2, + ) + self.glitchtip_dsn = legacy + return self + # External APIs (Stage 2) rosreestr_pkk_base_url: str = "https://pkk.rosreestr.ru/api/features/1" overpass_url: str = "https://overpass-api.de/api/interpreter" diff --git a/backend/app/main.py b/backend/app/main.py index 2bd294b5..43484e83 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -25,6 +25,7 @@ from app.api.v1 import ( photos, ) from app.core.config import settings +from app.observability.sentry_scrub import scrub_sensitive_query logger = logging.getLogger(__name__) @@ -35,10 +36,11 @@ if settings.glitchtip_dsn: sentry_sdk.init( dsn=settings.glitchtip_dsn, environment=settings.environment, - release=os.getenv("GIT_SHA", "unknown"), + release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown", traces_sample_rate=settings.glitchtip_traces_sample_rate, profiles_sample_rate=0.0, send_default_pii=False, + before_send_transaction=scrub_sensitive_query, integrations=[ StarletteIntegration(), FastApiIntegration(), diff --git a/backend/app/observability/__init__.py b/backend/app/observability/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/observability/sentry_scrub.py b/backend/app/observability/sentry_scrub.py new file mode 100644 index 00000000..af936c31 --- /dev/null +++ b/backend/app/observability/sentry_scrub.py @@ -0,0 +1,49 @@ +"""Хук before_send_transaction для GlitchTip/Sentry SDK. + +Redact-ит api keys / tokens из URL-spans перед отправкой — чтобы +секреты (apiKey=..., api_key=..., token=...) не утекали в GlitchTip +через HttpxIntegration performance-spans. +""" + +from __future__ import annotations + +import re +from typing import Any + +from sentry_sdk.types import Event + +_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 diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index d39e006b..06721b10 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -15,6 +15,7 @@ from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from app.core.config import settings +from app.observability.sentry_scrub import scrub_sensitive_query logger = logging.getLogger(__name__) @@ -25,10 +26,11 @@ if settings.glitchtip_dsn: sentry_sdk.init( dsn=settings.glitchtip_dsn, environment=settings.environment, - release=os.getenv("GIT_SHA", "unknown"), + release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown", traces_sample_rate=settings.glitchtip_traces_sample_rate, profiles_sample_rate=0.0, send_default_pii=False, + before_send_transaction=scrub_sensitive_query, integrations=[ CeleryIntegration(monitor_beat_tasks=True), SqlalchemyIntegration(), diff --git a/backend/tests/test_sentry_init.py b/backend/tests/test_sentry_init.py index a2858455..75538a31 100644 --- a/backend/tests/test_sentry_init.py +++ b/backend/tests/test_sentry_init.py @@ -1,9 +1,11 @@ """Unit-тесты логики инициализации GlitchTip SDK. Проверяем что init-блок в main.py / celery_app.py вызывает sentry_sdk.init() -только при непустом GLITCHTIP_DSN. +только при непустом GLITCHTIP_DSN, что release-fallback работает корректно, +и что scrub_sensitive_query redact-ит api keys из URL spans. """ +import os from unittest.mock import patch import sentry_sdk @@ -18,7 +20,6 @@ def test_sdk_imports_without_error() -> None: from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from sentry_sdk.integrations.starlette import StarletteIntegration - # Проверяем что классы инстанцируются без ошибок assert StarletteIntegration() assert FastApiIntegration() assert CeleryIntegration() @@ -30,7 +31,6 @@ def test_sdk_imports_without_error() -> None: def test_no_sdk_init_when_dsn_empty() -> None: """Если GLITCHTIP_DSN пустой, sentry_sdk.init() не должен вызываться.""" with patch("sentry_sdk.init") as mock_init: - # Симулируем логику guard из main.py: if settings.glitchtip_dsn: sentry_sdk.init(...) glitchtip_dsn = None if glitchtip_dsn: sentry_sdk.init(dsn=glitchtip_dsn) @@ -57,3 +57,102 @@ def test_sdk_init_called_when_dsn_set() -> None: assert call_kwargs["dsn"] == dsn assert call_kwargs["send_default_pii"] is False assert call_kwargs["profiles_sample_rate"] == 0.0 + + +# ── release fallback ────────────────────────────────────────────────────────── + + +def test_release_uses_git_sha_when_set() -> None: + """GIT_SHA имеет приоритет над SENTRY_RELEASE.""" + env = {"GIT_SHA": "abc1234", "SENTRY_RELEASE": "v1.0.0"} + with patch.dict(os.environ, env, clear=False): + release = os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown" + assert release == "abc1234" + + +def test_release_falls_back_to_sentry_release() -> None: + """Если GIT_SHA не задан, используется SENTRY_RELEASE.""" + env = {"SENTRY_RELEASE": "v1.2.3"} + with patch.dict(os.environ, env, clear=False): + # Убираем GIT_SHA если он есть + os.environ.pop("GIT_SHA", None) + release = os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown" + assert release == "v1.2.3" + + +def test_release_falls_back_to_unknown() -> None: + """Если ни одна переменная не задана, release='unknown'.""" + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("GIT_SHA", None) + os.environ.pop("SENTRY_RELEASE", None) + release = os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown" + assert release == "unknown" + + +# ── sentry_scrub ────────────────────────────────────────────────────────────── + + +def test_scrub_redacts_apikey_in_span_url() -> None: + """scrub_sensitive_query заменяет apiKey= в span data['url'].""" + from app.observability.sentry_scrub import scrub_sensitive_query + + event: dict = { + "spans": [ + { + "data": { + "url": "https://api.objctv.ru/v2/Report?apiKey=supersecret&group=EKB", + "http.url": "https://api.objctv.ru/v2/Report?api_key=topsecret", + } + } + ] + } + result = scrub_sensitive_query(event, {}) + span_data = result["spans"][0]["data"] + assert "[REDACTED]" in span_data["url"] + assert "supersecret" not in span_data["url"] + assert "[REDACTED]" in span_data["http.url"] + assert "topsecret" not in span_data["http.url"] + + +def test_scrub_redacts_token_in_description() -> None: + """scrub_sensitive_query заменяет token= в span description.""" + from app.observability.sentry_scrub import scrub_sensitive_query + + event: dict = { + "spans": [{"description": "GET https://example.com?token=mysecrettoken&foo=bar"}] + } + result = scrub_sensitive_query(event, {}) + assert "[REDACTED]" in result["spans"][0]["description"] + assert "mysecrettoken" not in result["spans"][0]["description"] + + +def test_scrub_redacts_request_url() -> None: + """scrub_sensitive_query заменяет token в event['request']['url'].""" + from app.observability.sentry_scrub import scrub_sensitive_query + + event: dict = {"request": {"url": "https://example.com?access_token=abc123&other=val"}} + result = scrub_sensitive_query(event, {}) + assert "[REDACTED]" in result["request"]["url"] + assert "abc123" not in result["request"]["url"] + + +def test_scrub_passes_through_clean_event() -> None: + """scrub_sensitive_query не трогает URL без чувствительных параметров.""" + from app.observability.sentry_scrub import scrub_sensitive_query + + event: dict = { + "spans": [{"data": {"url": "https://example.com?foo=bar&page=1"}}], + "request": {"url": "https://example.com/api/health"}, + } + result = scrub_sensitive_query(event, {}) + assert result["spans"][0]["data"]["url"] == "https://example.com?foo=bar&page=1" + assert result["request"]["url"] == "https://example.com/api/health" + + +def test_scrub_handles_missing_spans() -> None: + """scrub_sensitive_query не падает если 'spans' отсутствует.""" + from app.observability.sentry_scrub import scrub_sensitive_query + + event: dict = {"request": {"url": "https://example.com"}} + result = scrub_sensitive_query(event, {}) + assert result["request"]["url"] == "https://example.com"