Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions api/api/utils/sentry.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions api/conf/settings/sentry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
48 changes: 48 additions & 0 deletions api/test/unit/utils/test_sentry.py
Original file line number Diff line number Diff line change
@@ -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}"
Loading