From ff37d5673ad085fa48b59a24876d6043b4ef3212 Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Fri, 24 Jul 2026 15:51:55 -0300 Subject: [PATCH] Scrub the database hostname from Sentry events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit send_default_pii=False and Sentry's key-based scrubbing don't cover infrastructure hostnames that reach Sentry as free text — inside breadcrumbs, span descriptions or exception messages. The database DNS name can leak there. Add a before_send/before_send_transaction hook that redacts the configured database hostname wherever it appears in an event, before it leaves the process (client-side, so the value is never stored in Sentry at all). Non-sensitive defaults like localhost are ignored. Fixes #670 --- api/api/utils/sentry.py | 58 ++++++++++++++++++++++++++++++ api/conf/settings/sentry.py | 8 +++++ api/test/unit/utils/test_sentry.py | 48 +++++++++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 api/api/utils/sentry.py create mode 100644 api/test/unit/utils/test_sentry.py diff --git a/api/api/utils/sentry.py b/api/api/utils/sentry.py new file mode 100644 index 00000000000..a96b955a09f --- /dev/null +++ b/api/api/utils/sentry.py @@ -0,0 +1,58 @@ +""" +Utilities for scrubbing sensitive infrastructure values out of Sentry events. + +``send_default_pii=False`` and Sentry's server-side scrubbing only cover known +PII fields by key. Infrastructure hostnames such as the database DNS name can +still reach Sentry as free text — inside breadcrumbs, span descriptions or +exception messages — where key-based scrubbing never sees them. This redacts +those known-sensitive values from an event before it leaves the process. + +See https://github.com/WordPress/openverse/issues/670. +""" + +from collections.abc import Callable, Iterable +from typing import Any + + +FILTERED = "[Filtered]" + +# Hostnames that are not sensitive and are too broad to be worth redacting. +IGNORED_HOSTS = frozenset({"", "localhost", "127.0.0.1", "::1"}) + + +def _redact(value: Any, secrets: list[str]) -> Any: + if isinstance(value, str): + for secret in secrets: + value = value.replace(secret, FILTERED) + return value + if isinstance(value, dict): + return {key: _redact(item, secrets) for key, item in value.items()} + if isinstance(value, list): + return [_redact(item, secrets) for item in value] + if isinstance(value, tuple): + return tuple(_redact(item, secrets) for item in value) + return value + + +def make_sensitive_value_scrubber(hostnames: Iterable[str]) -> Callable: + """ + Build a Sentry ``before_send`` / ``before_send_transaction`` hook that + replaces the given hostnames wherever they appear, as substrings, anywhere + in the event. + + Longer hostnames are redacted first so that a host which is a substring of + another (e.g. ``db.example.com`` inside ``replica.db.example.com``) does not + leave a partial value behind. + """ + secrets = sorted( + {host for host in hostnames if host not in IGNORED_HOSTS}, + key=len, + reverse=True, + ) + + def before_send(event: Any, hint: Any = None) -> Any: + if not secrets: + return event + return _redact(event, secrets) + + return before_send diff --git a/api/conf/settings/sentry.py b/api/conf/settings/sentry.py index ae6d2452ee6..2c4e3ea26ab 100644 --- a/api/conf/settings/sentry.py +++ b/api/conf/settings/sentry.py @@ -3,7 +3,9 @@ from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.logging import LoggingIntegration, ignore_logger +from api.utils.sentry import make_sensitive_value_scrubber from conf.settings.base import ENVIRONMENT +from conf.settings.databases import DATABASES from conf.settings.security import DEBUG @@ -43,6 +45,10 @@ def profiles_sampler(sampling_context) -> float: LoggingIntegration(event_level=None, level=None), ] +# Redact infrastructure hostnames (e.g. the database DNS name) that can leak +# into events as free text, where key-based scrubbing never sees them. +scrub_sensitive_values = make_sensitive_value_scrubber([DATABASES["default"]["HOST"]]) + if not DEBUG and SENTRY_DSN: sentry_sdk.init( dsn=SENTRY_DSN, @@ -51,6 +57,8 @@ def profiles_sampler(sampling_context) -> float: profiles_sampler=profiles_sampler, send_default_pii=False, environment=ENVIRONMENT, + before_send=scrub_sensitive_values, + before_send_transaction=scrub_sensitive_values, ) # ALLOW_HOSTS is correctly configured so ignore this to prevent diff --git a/api/test/unit/utils/test_sentry.py b/api/test/unit/utils/test_sentry.py new file mode 100644 index 00000000000..11e2e89ca26 --- /dev/null +++ b/api/test/unit/utils/test_sentry.py @@ -0,0 +1,48 @@ +import pytest + +from api.utils.sentry import FILTERED, make_sensitive_value_scrubber + + +def test_redacts_hostname_anywhere_in_a_nested_event(): + scrub = make_sensitive_value_scrubber(["db.internal.example.com"]) + event = { + "message": "could not connect to db.internal.example.com:5432", + "breadcrumbs": { + "values": [{"message": "SELECT 1 -- db.internal.example.com"}], + }, + "extra": {"hosts": ("db.internal.example.com", "unrelated-host")}, + } + + scrubbed = scrub(event) + + assert "db.internal.example.com" not in repr(scrubbed) + assert FILTERED in scrubbed["message"] + assert scrubbed["breadcrumbs"]["values"][0]["message"].endswith(FILTERED) + # unrelated values are left untouched (and tuples stay tuples) + assert scrubbed["extra"]["hosts"] == (FILTERED, "unrelated-host") + + +@pytest.mark.parametrize("hostname", ["", "localhost", "127.0.0.1", "::1"]) +def test_ignores_non_sensitive_default_hosts(hostname): + scrub = make_sensitive_value_scrubber([hostname]) + event = {"message": f"connected to {hostname}"} + + # nothing to redact, so the event is returned unchanged + assert scrub(event) is event + + +def test_returns_event_unchanged_when_no_hostnames_given(): + scrub = make_sensitive_value_scrubber([]) + event = {"message": "hello"} + + assert scrub(event) is event + + +def test_redacts_longer_overlapping_hostname_first(): + scrub = make_sensitive_value_scrubber(["db.example.com", "replica.db.example.com"]) + event = {"message": "replica.db.example.com and db.example.com"} + + scrubbed = scrub(event) + + assert "example.com" not in scrubbed["message"] + assert scrubbed["message"] == f"{FILTERED} and {FILTERED}"