From a6a28246e2304dc308a19a38c7a51277f7a8921b Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Tue, 29 Sep 2026 13:37:53 -0400 Subject: [PATCH 1/3] ref(client)!: Remove `EventScrubber` and the `event_scrubber` option `data_collection` is now the default way to control what data the SDK collects, so the always-on `EventScrubber` is no longer needed. Removes `sentry_sdk.scrubber` (`EventScrubber`, `DEFAULT_DENYLIST`, `DEFAULT_PII_DENYLIST`), the scrubbing step in `_prepare_event`, and the `event_scrubber` init option. Passing `event_scrubber=` to `init` now raises a `TypeError`. Refs PY-2798 Refs #7566 --- docs/apidocs.rst | 3 - sentry_sdk/client.py | 21 -- sentry_sdk/consts.py | 8 - sentry_sdk/scrubber.py | 178 ----------- tests/integrations/tornado/test_tornado.py | 5 +- tests/test_scrubber.py | 327 --------------------- 6 files changed, 1 insertion(+), 541 deletions(-) delete mode 100644 sentry_sdk/scrubber.py delete mode 100644 tests/test_scrubber.py diff --git a/docs/apidocs.rst b/docs/apidocs.rst index dd16a59233..b06e1572f1 100644 --- a/docs/apidocs.rst +++ b/docs/apidocs.rst @@ -35,9 +35,6 @@ API Docs .. autoclass:: sentry_sdk.attachments.Attachment :members: -.. autoclass:: sentry_sdk.scrubber.EventScrubber - :members: - .. autoclass:: sentry_sdk.monitor.Monitor :members: diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 4bb5181aab..7692427fa3 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -27,7 +27,6 @@ from sentry_sdk.integrations.dedupe import DedupeIntegration from sentry_sdk.monitor import Monitor from sentry_sdk.profiler.continuous_profiler import setup_continuous_profiler -from sentry_sdk.scrubber import EventScrubber from sentry_sdk.serializer import serialize from sentry_sdk.sessions import SessionFlusher from sentry_sdk.traces import Span, trace @@ -49,7 +48,6 @@ get_sdk_name, get_type_name, handle_in_app, - has_data_collection_enabled, logger, ) @@ -132,20 +130,6 @@ def _get_options(*args: "Optional[str]", **kwargs: "Any") -> "Dict[str, Any]": rv["data_collection"] = _resolve_data_collection(rv) - # Do not add the event scrubber if data collection is enabled as it can remove data that's - # collected under data collection config - if not has_data_collection_enabled(rv) and rv["event_scrubber"] is None: - rv["event_scrubber"] = EventScrubber( - send_default_pii=False - if rv["send_default_pii"] is None - else rv["send_default_pii"] - ) - elif has_data_collection_enabled(rv) and rv["event_scrubber"]: - logger.warning( - "Event scrubbers are not enabled when data collection configuration is provided. Ignoring event_scrubber...", - ) - rv["event_scrubber"] = None - if rv["socket_options"] and not isinstance(rv["socket_options"], list): logger.warning( "Ignoring socket_options because of unexpected format. See urllib3.HTTPConnection.socket_options for the expected format." @@ -571,11 +555,6 @@ def _prepare_event( self.options["project_root"], ) - if event is not None: - event_scrubber = self.options["event_scrubber"] - if event_scrubber: - event_scrubber.scrub_event(event) - if previous_total_breadcrumbs is not None: event["breadcrumbs"] = AnnotatedValue( event.get("breadcrumbs", {"values": []}), diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 5e61845f15..3f43edc50e 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -1296,7 +1296,6 @@ def __init__( MATCH_ALL ], functions_to_trace: "Sequence[Dict[str, str]]" = [], # noqa: B006 - event_scrubber: "Optional[sentry_sdk.scrubber.EventScrubber]" = None, max_value_length: "Optional[int]" = DEFAULT_MAX_VALUE_LENGTH, enable_backpressure_handling: bool = True, error_sampler: "Optional[Callable[[Event, Hint], Union[float, bool]]]" = None, @@ -1429,13 +1428,6 @@ def __init__( See https://docs.sentry.io/platforms/python/configuration/options/#data_collection for more details. - :param event_scrubber: Scrubs the event payload for sensitive information such as cookies, sessions, and - passwords from a `denylist`. - - It can additionally be used to scrub from another `pii_denylist` if `send_default_pii` is disabled. - - See how to `configure the scrubber here `_. - :param include_source_context: When enabled, source context will be included in events sent to Sentry. This source context includes the five lines of code above and below the line of code where an error diff --git a/sentry_sdk/scrubber.py b/sentry_sdk/scrubber.py deleted file mode 100644 index 5f9a3e5df6..0000000000 --- a/sentry_sdk/scrubber.py +++ /dev/null @@ -1,178 +0,0 @@ -from typing import TYPE_CHECKING, Dict, List, cast - -from sentry_sdk.utils import ( - AnnotatedValue, - capture_internal_exceptions, - iter_event_frames, -) - -if TYPE_CHECKING: - from typing import Optional - - from sentry_sdk._types import Event - - -DEFAULT_DENYLIST = [ - # stolen from relay - "password", - "passwd", - "secret", - "api_key", - "apikey", - "auth", - "credentials", - "mysql_pwd", - "privatekey", - "private_key", - "token", - "session", - # django - "csrftoken", - "sessionid", - # wsgi - "x_csrftoken", - "x_forwarded_for", - "set_cookie", - "cookie", - "authorization", - "proxy-authorization", - "x_api_key", - # other common names used in the wild - "aiohttp_session", # aiohttp - "connect.sid", # Express - "csrf_token", # Pyramid - "csrf", # (this is a cookie name used in accepted answers on stack overflow) - "_csrf", # Express - "_csrf_token", # Bottle - "PHPSESSID", # PHP - "_session", # Sanic - "symfony", # Symfony - "user_session", # Vue - "_xsrf", # Tornado - "XSRF-TOKEN", # Angular, Laravel -] - -DEFAULT_PII_DENYLIST = [ - "x_forwarded_for", - "x_real_ip", - "ip_address", - "remote_addr", -] - - -class EventScrubber: - def __init__( - self, - denylist: "Optional[List[str]]" = None, - recursive: bool = False, - send_default_pii: bool = False, - pii_denylist: "Optional[List[str]]" = None, - ) -> None: - """ - A scrubber that goes through the event payload and removes sensitive data configured through denylists. - - :param denylist: A security denylist that is always scrubbed, defaults to DEFAULT_DENYLIST. - :param recursive: Whether to scrub the event payload recursively, default False. - :param send_default_pii: Whether pii is sending is on, pii fields are not scrubbed. - :param pii_denylist: The denylist to use for scrubbing when pii is not sent, defaults to DEFAULT_PII_DENYLIST. - """ - self.denylist = DEFAULT_DENYLIST.copy() if denylist is None else denylist - - if not send_default_pii: - pii_denylist = ( - DEFAULT_PII_DENYLIST.copy() if pii_denylist is None else pii_denylist - ) - self.denylist += pii_denylist - - self.denylist = [x.lower() for x in self.denylist] - self.recursive = recursive - - def scrub_list(self, lst: object) -> None: - """ - If a list is passed to this method, the method recursively searches the list and any - nested lists for any dictionaries. The method calls scrub_dict on all dictionaries - it finds. - If the parameter passed to this method is not a list, the method does nothing. - """ - if not isinstance(lst, list): - return - - for v in lst: - self.scrub_dict(v) # no-op unless v is a dict - self.scrub_list(v) # no-op unless v is a list - - def scrub_dict(self, d: object) -> None: - """ - If a dictionary is passed to this method, the method scrubs the dictionary of any - sensitive data. The method calls itself recursively on any nested dictionaries ( - including dictionaries nested in lists) if self.recursive is True. - This method does nothing if the parameter passed to it is not a dictionary. - """ - if not isinstance(d, dict): - return - - for k, v in d.items(): - # The cast is needed because mypy is not smart enough to figure out that k must be a - # string after the isinstance check. - if isinstance(k, str) and k.lower() in self.denylist: - d[k] = AnnotatedValue.substituted_because_contains_sensitive_data() - elif self.recursive: - self.scrub_dict(v) # no-op unless v is a dict - self.scrub_list(v) # no-op unless v is a list - - def scrub_request(self, event: "Event") -> None: - with capture_internal_exceptions(): - if "request" in event: - if "headers" in event["request"]: - self.scrub_dict(event["request"]["headers"]) - if "cookies" in event["request"]: - self.scrub_dict(event["request"]["cookies"]) - if "data" in event["request"]: - data = event["request"]["data"] - self.scrub_dict(data) # no-op unless data is a dict - self.scrub_list(data) # no-op unless data is a list - - def scrub_extra(self, event: "Event") -> None: - with capture_internal_exceptions(): - if "extra" in event: - self.scrub_dict(event["extra"]) - - def scrub_user(self, event: "Event") -> None: - with capture_internal_exceptions(): - if "user" in event: - user = event["user"] - if "ip_address" in self.denylist and isinstance(user, dict): - user.pop("ip_address", None) - self.scrub_dict(user) - - def scrub_breadcrumbs(self, event: "Event") -> None: - with capture_internal_exceptions(): - if "breadcrumbs" in event: - if ( - not isinstance(event["breadcrumbs"], AnnotatedValue) - and "values" in event["breadcrumbs"] - ): - for value in event["breadcrumbs"]["values"]: - if "data" in value: - self.scrub_dict(value["data"]) - - def scrub_frames(self, event: "Event") -> None: - with capture_internal_exceptions(): - for frame in iter_event_frames(event): - if "vars" in frame: - self.scrub_dict(frame["vars"]) - - def scrub_spans(self, event: "Event") -> None: - with capture_internal_exceptions(): - if "spans" in event: - for span in cast(List[Dict[str, object]], event["spans"]): - if "data" in span: - self.scrub_dict(span["data"]) - - def scrub_event(self, event: "Event") -> None: - self.scrub_request(event) - self.scrub_extra(event) - self.scrub_user(event) - self.scrub_breadcrumbs(event) - self.scrub_frames(event) - self.scrub_spans(event) diff --git a/tests/integrations/tornado/test_tornado.py b/tests/integrations/tornado/test_tornado.py index d87e8e4a96..ffe5b68aed 100644 --- a/tests/integrations/tornado/test_tornado.py +++ b/tests/integrations/tornado/test_tornado.py @@ -76,10 +76,7 @@ def get(self): # Sent by every data-collection cookie test below. Mixes benign cookies # (``theme``, ``lang``) with ones whose names match the data-collection -# sensitive denylist (``jwt``, ``identity``). Those two names are deliberately -# NOT in the ``EventScrubber`` denylist (which matches keys exactly), so any -# filtering we observe on them comes from the extractor's data-collection -# logic, not the always-on scrubber. +# sensitive denylist (``jwt``, ``identity``). COOKIE_HEADER = "jwt=tokenval; theme=dark; lang=en; identity=alice" diff --git a/tests/test_scrubber.py b/tests/test_scrubber.py deleted file mode 100644 index 45cbedbb44..0000000000 --- a/tests/test_scrubber.py +++ /dev/null @@ -1,327 +0,0 @@ -import logging -import sys - -from sentry_sdk import capture_event, capture_exception -from sentry_sdk.integrations.logging import LoggingIntegration -from sentry_sdk.scrubber import EventScrubber -from sentry_sdk.utils import event_from_exception - -logger = logging.getLogger(__name__) -logger.setLevel(logging.DEBUG) - - -def test_request_scrubbing(sentry_init, capture_events): - sentry_init() - events = capture_events() - - try: - 1 / 0 - except ZeroDivisionError: - ev, _hint = event_from_exception(sys.exc_info()) - - ev["request"] = { - "headers": { - "COOKIE": "secret", - "authorization": "Bearer bla", - "ORIGIN": "google.com", - "ip_address": "127.0.0.1", - }, - "cookies": { - "sessionid": "secret", - "foo": "bar", - }, - "data": { - "token": "secret", - "foo": "bar", - }, - } - - capture_event(ev) - - (event,) = events - - assert event["request"] == { - "headers": { - "COOKIE": "[Filtered]", - "authorization": "[Filtered]", - "ORIGIN": "google.com", - "ip_address": "[Filtered]", - }, - "cookies": {"sessionid": "[Filtered]", "foo": "bar"}, - "data": {"token": "[Filtered]", "foo": "bar"}, - } - - assert event["_meta"]["request"] == { - "headers": { - "COOKIE": {"": {"rem": [["!config", "s"]]}}, - "authorization": {"": {"rem": [["!config", "s"]]}}, - "ip_address": {"": {"rem": [["!config", "s"]]}}, - }, - "cookies": {"sessionid": {"": {"rem": [["!config", "s"]]}}}, - "data": {"token": {"": {"rem": [["!config", "s"]]}}}, - } - - -def test_request_scrubbing_list_body(sentry_init, capture_events): - sentry_init() - events = capture_events() - - try: - 1 / 0 - except ZeroDivisionError: - ev, _hint = event_from_exception(sys.exc_info()) - - ev["request"] = { - "data": [ - {"token": "secret", "foo": "bar"}, - {"password": "secret", "baz": "qux"}, - ], - } - - capture_event(ev) - - (event,) = events - - assert event["request"] == { - "data": [ - {"token": "[Filtered]", "foo": "bar"}, - {"password": "[Filtered]", "baz": "qux"}, - ], - } - - assert event["_meta"]["request"] == { - "data": { - "0": {"token": {"": {"rem": [["!config", "s"]]}}}, - "1": {"password": {"": {"rem": [["!config", "s"]]}}}, - } - } - - -def test_recursive_request_scrubbing_list_body(sentry_init, capture_events): - sentry_init(event_scrubber=EventScrubber(recursive=True)) - events = capture_events() - - try: - 1 / 0 - except ZeroDivisionError: - ev, _hint = event_from_exception(sys.exc_info()) - - ev["request"] = {"data": [{"deep": {"password": "secret"}}]} - - capture_event(ev) - - (event,) = events - - assert event["request"] == {"data": [{"deep": {"password": "[Filtered]"}}]} - - -def test_ip_address_not_scrubbed_when_pii_enabled(sentry_init, capture_events): - sentry_init(send_default_pii=True) - events = capture_events() - - try: - 1 / 0 - except ZeroDivisionError: - ev, _hint = event_from_exception(sys.exc_info()) - - ev["request"] = {"headers": {"COOKIE": "secret", "ip_address": "127.0.0.1"}} - - capture_event(ev) - - (event,) = events - - assert event["request"] == { - "headers": {"COOKIE": "[Filtered]", "ip_address": "127.0.0.1"} - } - - assert event["_meta"]["request"] == { - "headers": { - "COOKIE": {"": {"rem": [["!config", "s"]]}}, - } - } - - -def test_user_ip_address_removed_when_pii_disabled(sentry_init, capture_events): - sentry_init() - events = capture_events() - - try: - 1 / 0 - except ZeroDivisionError: - ev, _hint = event_from_exception(sys.exc_info()) - ev["user"] = {"id": "42", "ip_address": "127.0.0.1"} - - capture_event(ev) - - (event,) = events - - assert event["user"] == {"id": "42"} - assert "user" not in event.get("_meta", {}) - - -def test_user_ip_address_not_removed_when_pii_enabled(sentry_init, capture_events): - sentry_init(send_default_pii=True) - events = capture_events() - - try: - 1 / 0 - except ZeroDivisionError: - ev, _hint = event_from_exception(sys.exc_info()) - ev["user"] = {"id": "42", "ip_address": "127.0.0.1"} - - capture_event(ev) - - (event,) = events - - assert event["user"] == {"id": "42", "ip_address": "127.0.0.1"} - assert "user" not in event.get("_meta", {}) - - -def test_stack_var_scrubbing(sentry_init, capture_events): - sentry_init() - events = capture_events() - - try: - password = "supersecret" # noqa - api_key = "1231231231" # noqa - safe = "keepthis" # noqa - 1 / 0 - except ZeroDivisionError: - capture_exception() - - (event,) = events - - frames = event["exception"]["values"][0]["stacktrace"]["frames"] - (frame,) = frames - assert frame["vars"]["password"] == "[Filtered]" - assert frame["vars"]["api_key"] == "[Filtered]" - assert frame["vars"]["safe"] == "'keepthis'" - - meta = event["_meta"]["exception"]["values"]["0"]["stacktrace"]["frames"]["0"][ - "vars" - ] - assert meta == { - "password": {"": {"rem": [["!config", "s"]]}}, - "api_key": {"": {"rem": [["!config", "s"]]}}, - } - - -def test_breadcrumb_extra_scrubbing(sentry_init, capture_events): - sentry_init( - max_breadcrumbs=2, - integrations=[ - LoggingIntegration(breadcrumb_level=logging.INFO, event_level=logging.ERROR) - ], - ) - - events = capture_events() - - logger.info("breadcrumb 1", extra=dict(foo=1, password="secret")) - logger.info("breadcrumb 2", extra=dict(bar=2, auth="secret")) - logger.info("breadcrumb 3", extra=dict(foobar=3, password="secret")) - logger.critical("whoops", extra=dict(bar=69, auth="secret")) - - (event,) = events - - assert event["extra"]["bar"] == 69 - assert event["extra"]["auth"] == "[Filtered]" - assert event["breadcrumbs"]["values"][0]["data"] == { - "bar": 2, - "auth": "[Filtered]", - } - assert event["breadcrumbs"]["values"][1]["data"] == { - "foobar": 3, - "password": "[Filtered]", - } - - assert event["_meta"]["extra"]["auth"] == {"": {"rem": [["!config", "s"]]}} - assert event["_meta"]["breadcrumbs"] == { - "": {"len": 3}, - "values": { - "0": {"data": {"auth": {"": {"rem": [["!config", "s"]]}}}}, - "1": {"data": {"password": {"": {"rem": [["!config", "s"]]}}}}, - }, - } - - -def test_custom_denylist(sentry_init, capture_events): - sentry_init( - event_scrubber=EventScrubber( - denylist=["my_sensitive_var"], pii_denylist=["my_pii_var"] - ) - ) - events = capture_events() - - try: - my_sensitive_var = "secret" # noqa - my_pii_var = "jane.doe" # noqa - safe = "keepthis" # noqa - 1 / 0 - except ZeroDivisionError: - capture_exception() - - (event,) = events - - frames = event["exception"]["values"][0]["stacktrace"]["frames"] - (frame,) = frames - assert frame["vars"]["my_sensitive_var"] == "[Filtered]" - assert frame["vars"]["my_pii_var"] == "[Filtered]" - assert frame["vars"]["safe"] == "'keepthis'" - - meta = event["_meta"]["exception"]["values"]["0"]["stacktrace"]["frames"]["0"][ - "vars" - ] - assert meta == { - "my_sensitive_var": {"": {"rem": [["!config", "s"]]}}, - "my_pii_var": {"": {"rem": [["!config", "s"]]}}, - } - - -def test_scrubbing_doesnt_affect_local_vars(sentry_init, capture_events): - sentry_init() - events = capture_events() - - try: - password = "cat123" - 1 / 0 - except ZeroDivisionError: - capture_exception() - - (event,) = events - - frames = event["exception"]["values"][0]["stacktrace"]["frames"] - (frame,) = frames - assert frame["vars"]["password"] == "[Filtered]" - assert password == "cat123" - - -def test_recursive_event_scrubber(sentry_init, capture_events): - sentry_init(event_scrubber=EventScrubber(recursive=True)) - events = capture_events() - complex_structure = { - "deep": { - "deeper": [{"deepest": {"password": "my_darkest_secret"}}], - }, - } - - capture_event({"extra": complex_structure}) - - (event,) = events - assert event["extra"]["deep"]["deeper"][0]["deepest"]["password"] == "'[Filtered]'" - - -def test_recursive_scrubber_does_not_override_original(sentry_init, capture_events): - sentry_init(event_scrubber=EventScrubber(recursive=True)) - events = capture_events() - - data = {"csrf": "secret"} - try: - raise RuntimeError("An error") - except Exception: - capture_exception() - - (event,) = events - frames = event["exception"]["values"][0]["stacktrace"]["frames"] - (frame,) = frames - assert data["csrf"] == "secret" - assert frame["vars"]["data"]["csrf"] == "[Filtered]" From 444055f365521722facbdbfd83b3ac665938c40f Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Tue, 29 Sep 2026 14:19:34 -0400 Subject: [PATCH 2/3] test(flask): Update request-not-modified test for scrubber removal The body is no longer redacted by `EventScrubber`, so assert the raw value. Opt into `data_collection` and disable flask_login, whose module-level login manager has no user_loader, so user info collection doesn't raise. --- tests/integrations/flask/test_flask.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/integrations/flask/test_flask.py b/tests/integrations/flask/test_flask.py index da18734743..7ee3f4fc21 100644 --- a/tests/integrations/flask/test_flask.py +++ b/tests/integrations/flask/test_flask.py @@ -944,13 +944,17 @@ def index(): assert response.data == b"hi" -def test_request_not_modified_by_reference(sentry_init, capture_events, app): +def test_request_not_modified_by_reference( + sentry_init, capture_events, app, monkeypatch +): sentry_init( integrations=[ flask_sentry.FlaskIntegration(), LoggingIntegration(event_level=logging.ERROR), - ] + ], + data_collection={}, ) + monkeypatch.setattr(flask_sentry, "flask_login", None) @app.route("/", methods=["POST"]) def index(): @@ -974,7 +978,9 @@ def index(): (event,) = events - assert event["request"]["data"]["password"] == "[Filtered]" + # In data collection, request bodies need to be handled in the before_send + # callback, so it's expected that the event will contain the raw value. + assert event["request"]["data"]["password"] == "ohno" assert event["request"]["headers"]["Authorization"] == "[Filtered]" assert event["request"]["headers"]["Proxy-Authorization"] == "[Filtered]" From 08b6c64457eb5d6dc48be91484510a957facd31c Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Tue, 29 Sep 2026 14:24:35 -0400 Subject: [PATCH 3/3] . --- tests/integrations/fastapi/test_fastapi.py | 6 ++++-- tests/integrations/starlette/test_starlette.py | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index 0439326881..8aa92e1b32 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -190,7 +190,8 @@ async def test_formdata_request_body(sentry_init, capture_items): (event,) = (item.payload for item in items if item.type == "event") assert event["request"]["data"].keys() == PARSED_FORM.keys() assert event["request"]["data"]["username"] == PARSED_FORM["username"] - assert event["request"]["data"]["password"] == "[Filtered]" + # Expectation in data collection is that the user scrubs this within `before_send` + assert event["request"]["data"]["password"] == "hello123" assert event["request"]["data"]["photo"] == "" sentry_sdk.flush() @@ -506,7 +507,8 @@ async def _error(request: Request): ) event = events[0] - assert event["request"]["data"] == {"password": "[Filtered]"} + # Expectation in data collection is that the user scrubs this within `before_send` + assert event["request"]["data"] == {"password": "secret"} assert event["request"]["headers"]["authorization"] == "[Filtered]" assert event["request"]["headers"]["proxy-authorization"] == "[Filtered]" diff --git a/tests/integrations/starlette/test_starlette.py b/tests/integrations/starlette/test_starlette.py index 90b663b4a6..9444e4fc86 100644 --- a/tests/integrations/starlette/test_starlette.py +++ b/tests/integrations/starlette/test_starlette.py @@ -1417,7 +1417,8 @@ async def _error(request): ) event = events[0] - assert event["request"]["data"] == {"password": "[Filtered]"} + # Expectation in data collection is that the user scrubs this within `before_send` + assert event["request"]["data"] == {"password": "ohno"} assert event["request"]["headers"]["authorization"] == "[Filtered]" assert event["request"]["headers"]["proxy-authorization"] == "[Filtered]"