From 11e8470aa527cfefcb1bfb6ecddd68525aa6dfc0 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Thu, 1 Oct 2026 19:46:22 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F(summary)=20redact=20meeti?= =?UTF-8?q?ng=20content=20from=20Sentry=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sentry attaches stack frame locals to its events. When storing a transcript in S3 failed, the full transcript held in `data` and `transcript` was sent to Sentry. Keep locals for debugging, but scrub variables and nested dict keys known to hold transcripts, summaries, LLM prompts, participants' personal data or pre-signed URLs. Share the Sentry init between the API and the Celery worker, and never send request bodies. --- CHANGELOG.md | 1 + src/summary/summary/core/celery_worker.py | 11 +- src/summary/summary/core/sentry.py | 94 ++++++++++ src/summary/summary/main.py | 5 +- src/summary/tests/unit/test_sentry.py | 207 ++++++++++++++++++++++ 5 files changed, 309 insertions(+), 9 deletions(-) create mode 100644 src/summary/summary/core/sentry.py create mode 100644 src/summary/tests/unit/test_sentry.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5afe3fc0..df3a7db2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to - 🔒️(agents) upgrade libpcre2-8-0 to fix CVE-2026-103111 - 🔒️(frontend) upgrade pcre2 to fix CVE-2026-103111 - 🐛(summary) disable default S3 checksums for GCS-compatible storage +- 🔒️(summary) redact meeting content from Sentry events ## [1.33.0] - 2026-09-30 diff --git a/src/summary/summary/core/celery_worker.py b/src/summary/summary/core/celery_worker.py index 4d37ad1a..2e4c732d 100644 --- a/src/summary/summary/core/celery_worker.py +++ b/src/summary/summary/core/celery_worker.py @@ -9,7 +9,6 @@ from typing import Any from urllib.parse import urljoin import requests -import sentry_sdk from celery import Celery, signals from celery.utils.log import get_task_logger from openai.types.audio import Transcription @@ -42,6 +41,7 @@ from summary.core.prompt import ( PROMPT_SYSTEM_TLDR, PROMPT_USER_PART, ) +from summary.core.sentry import init_sentry from summary.core.shared_models import ( SummarizeWebhookFailurePayload, SummarizeWebhookSuccessPayload, @@ -75,12 +75,11 @@ celery = Celery( celery.config_from_object("summary.core.celery_config") -if settings.sentry_dsn and settings.sentry_is_enabled: - @signals.celeryd_init.connect - def init_sentry(**_kwargs): - """Initialize sentry.""" - sentry_sdk.init(dsn=settings.sentry_dsn, enable_tracing=True) +@signals.celeryd_init.connect +def init_celery_sentry(**_kwargs): + """Initialize Sentry in the Celery worker.""" + init_sentry() file_service = FileService() diff --git a/src/summary/summary/core/sentry.py b/src/summary/summary/core/sentry.py new file mode 100644 index 00000000..422a255b --- /dev/null +++ b/src/summary/summary/core/sentry.py @@ -0,0 +1,94 @@ +"""Sentry configuration.""" + +import sentry_sdk +from sentry_sdk.scrubber import DEFAULT_DENYLIST, DEFAULT_PII_DENYLIST, EventScrubber + +from summary.core.config import get_settings + +# Exact names (case-insensitive) of variables and dict keys to redact. +SENSITIVE_DATA_DENYLIST = [ + # Raw payloads and serialized bodies + "data", + "body", + "payload", + "args", + "kwargs", + "response", + "res", + # Transcripts + "transcript", + "transcription", + "transcription_json", + "transcription_res", + "new_transcription", + "segments", + "word_segments", + "words", + "text", + "content", + "formatted_output", + # Summaries and LLM exchanges + "summary", + "raw_summary", + "cleaned_summary", + "tldr", + "part", + "parts", + "parts_summarized", + "next_steps", + "title", + "titles", + "action", + "line", + "lines", + "user_prompt", + "prompt_user_part", + "messages", + "json_data", # OpenAI client internals + "opts", + "options", + "input_options", + # Participants' personal data + "email", + "user_email", + "assignees", + "participant_name", + "participant_names", + "participants_info", + "speaker_to_name", + # Signed / pre-authenticated URLs + "cloud_storage_url", + "transcription_data_url", + "summary_data_url", +] + + +def build_event_scrubber() -> EventScrubber: + """Build the scrubber redacting meeting content and personal data.""" + return EventScrubber( + denylist=DEFAULT_DENYLIST + SENSITIVE_DATA_DENYLIST, + pii_denylist=DEFAULT_PII_DENYLIST, + recursive=True, + ) + + +def init_sentry() -> None: + """Initialize Sentry if enabled in the settings.""" + settings = get_settings() + + if not settings.sentry_is_enabled: + return + + if not settings.sentry_dsn: + return + + sentry_sdk.init( + dsn=settings.sentry_dsn, + enable_tracing=True, + # Never attach request bodies, Celery task arguments or user data. + send_default_pii=False, + # Task creation requests carry the content to summarize. + max_request_body_size="never", + include_local_variables=True, + event_scrubber=build_event_scrubber(), + ) diff --git a/src/summary/summary/main.py b/src/summary/summary/main.py index 2f5659a9..a7d0e2d2 100644 --- a/src/summary/summary/main.py +++ b/src/summary/summary/main.py @@ -1,18 +1,17 @@ """Application.""" -import sentry_sdk from dockerflow.fastapi import router as dockerflow_router from fastapi import FastAPI from summary.api.main import api_router_v2 from summary.core import checks # noqa: F401 -- registers the Dockerflow checks from summary.core.config import get_settings +from summary.core.sentry import init_sentry settings = get_settings() -if settings.sentry_dsn and settings.sentry_is_enabled: - sentry_sdk.init(dsn=settings.sentry_dsn, enable_tracing=True) +init_sentry() app = FastAPI( title=settings.app_name, diff --git a/src/summary/tests/unit/test_sentry.py b/src/summary/tests/unit/test_sentry.py new file mode 100644 index 00000000..5ec3c634 --- /dev/null +++ b/src/summary/tests/unit/test_sentry.py @@ -0,0 +1,207 @@ +"""Tests for the Sentry configuration. + +Each test raises an error from code handling meeting content and inspects the +event Sentry would send: the content must be redacted, while harmless local +variables are kept for debugging. +""" + +import json +from collections.abc import Callable, Iterator +from unittest.mock import Mock + +import httpx +import openai +import pytest +import sentry_sdk +from botocore.exceptions import ClientError +from botocore.stub import Stubber +from sentry_sdk.transport import Transport + +from summary.core import file_service +from summary.core import sentry as sentry_module +from summary.core.file_service import FileService +from summary.core.llm_service import LLMException, LLMService +from summary.core.shared_models import WhisperXResponse + +CANARY = "CANARY-MEETING-CONTENT" + +SentryEvents = Callable[[], list[str]] + + +class _CapturingTransport(Transport): + """Keep serialized events in memory instead of sending them.""" + + def __init__(self): + super().__init__() + self.events: list[str] = [] + + def capture_envelope(self, envelope): + """Store each serialized event of the envelope.""" + for item in envelope.items: + if item.type == "event": + self.events.append(item.payload.get_bytes().decode()) + + +@pytest.fixture +def sentry_events() -> Iterator[SentryEvents]: + """Initialize Sentry as in production, with an in-memory transport.""" + transport = _CapturingTransport() + sentry_sdk.init( + dsn="https://public@sentry.example.com/1", + transport=transport, + send_default_pii=False, + include_local_variables=True, + event_scrubber=sentry_module.build_event_scrubber(), + default_integrations=False, + ) + + def flush() -> list[str]: + sentry_sdk.flush() + return transport.events + + yield flush + sentry_sdk.init() # Disable Sentry for the following tests + + +def _transcript() -> WhisperXResponse: + return WhisperXResponse.model_validate( + { + "segments": [ + { + "start": 0.0, + "end": 1.0, + "text": f"I don't know {CANARY}", + "speaker": "SPEAKER_01", + "words": [ + { + "word": CANARY, + "start": 0.0, + "end": 1.0, + "score": 0.9, + "speaker": "SPEAKER_01", + } + ], + } + ] + } + ) + + +def _local_vars(event: str) -> list[dict]: + """Return the local variables of every frame of the event.""" + return [ + frame.get("vars", {}) + for exception in json.loads(event)["exception"]["values"] + for frame in exception["stacktrace"]["frames"] + ] + + +@pytest.fixture +def s3_stubber(monkeypatch: pytest.MonkeyPatch) -> Iterator[Stubber]: + """Stub the S3 client built by the file service.""" + monkeypatch.setattr( + file_service, + "settings", + file_service.settings.model_copy( + update={ + "aws_s3_endpoint_url": "garage:9000", + "aws_s3_secure_access": False, + "aws_s3_region_name": "fr-par", + "aws_storage_bucket_name": "meet-media-storage", + } + ), + ) + stubber = Stubber(file_service._build_s3_client()) + stubber.activate() + monkeypatch.setattr(file_service, "_build_s3_client", lambda: stubber.client) + yield stubber + stubber.assert_no_pending_responses() + + +def test_sentry_store_transcript_failure_redacts_transcript( + sentry_events: SentryEvents, s3_stubber: Stubber +) -> None: + """A failed S3 upload does not send the transcript, but keeps the job id.""" + s3_stubber.add_client_error("put_object", service_error_code="InvalidDigest") + + try: + FileService().store_transcript(transcript=_transcript(), job_id="job-1") + except ClientError: + sentry_sdk.capture_exception() + else: + pytest.fail("store_transcript should have failed") + + [event] = sentry_events() + assert CANARY not in event + + store_transcript_vars = next( + frame_vars + for frame_vars in _local_vars(event) + if "transcript_path" in frame_vars + ) + assert store_transcript_vars["job_id"] == "'job-1'" + assert store_transcript_vars["transcript_path"] == "'transcripts/job-1.json'" + assert store_transcript_vars["data"] == "[Filtered]" + assert store_transcript_vars["transcript"] == "[Filtered]" + + +def test_sentry_llm_failure_redacts_prompts(sentry_events: SentryEvents) -> None: + """A failed LLM call does not send the prompts, even from OpenAI internals.""" + client = openai.OpenAI( + api_key="test-key", + base_url="https://llm.example.com/v1", + max_retries=0, + http_client=httpx.Client( + transport=httpx.MockTransport(lambda request: httpx.Response(500)) + ), + ) + observability = Mock(is_enabled=False) + observability.get_openai_client.return_value = client + + try: + LLMService(observability).call( + system_prompt="Summarize this meeting.", + user_prompt=f"Transcript: {CANARY}", + name="tldr", + ) + except LLMException: + sentry_sdk.capture_exception() + else: + pytest.fail("the LLM call should have failed") + + [event] = sentry_events() + assert CANARY not in event + + +def test_init_sentry_uses_the_event_scrubber(monkeypatch: pytest.MonkeyPatch) -> None: + """Sentry is initialized with local variables and the content scrubber.""" + settings = sentry_module.get_settings().model_copy( + update={"sentry_is_enabled": True, "sentry_dsn": "https://k@example.com/1"} + ) + monkeypatch.setattr(sentry_module, "get_settings", lambda: settings) + init = Mock() + monkeypatch.setattr(sentry_module.sentry_sdk, "init", init) + + sentry_module.init_sentry() + + init.assert_called_once() + kwargs = init.call_args.kwargs + assert kwargs["send_default_pii"] is False + assert kwargs["max_request_body_size"] == "never" + assert kwargs["include_local_variables"] is True + denylist = kwargs["event_scrubber"].denylist + assert {"data", "transcript", "content", "summary", "password"} <= set(denylist) + + +def test_init_sentry_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + """Sentry is not initialized when disabled.""" + settings = sentry_module.get_settings().model_copy( + update={"sentry_is_enabled": False, "sentry_dsn": "https://k@example.com/1"} + ) + monkeypatch.setattr(sentry_module, "get_settings", lambda: settings) + init = Mock() + monkeypatch.setattr(sentry_module.sentry_sdk, "init", init) + + sentry_module.init_sentry() + + init.assert_not_called()