Repository: getsentry/sentry-python

Issue:
Make EventScrubber recursive
### Problem Statement

We have a custom `before_send` implementation that scrubs data recursively. I was hopping to replace the custom implementation with the built-in EventScrubber but I found out that it doesn't scrub `vars` recursively.

As far as I can tell this was a consistency, perf trade-off thing but it would be nice to have a built-in option to make it recursive.

Thank you!

### Solution Brainstorm

`EventScrubber(recursive=True)`


Discussion:
Hey @phb-teleclinic, thanks for the suggestion, we can add this as an optional flag.

If anyone reading this feels like making a PR, please go ahead -- it'll probably take some time for us to get around to this.
for completeness, we considered this here
https://github.com/getsentry/rfcs/blob/main/text/0062-controlling-pii-and-credentials-in-sd-ks.md#a-walk-whole-tree
but decided against it due to computational overhead.
taking a look at this. still a noob but this looks doable

Resolving patch:
diff --git a/sentry_sdk/scrubber.py b/sentry_sdk/scrubber.py
--- a/sentry_sdk/scrubber.py
+++ b/sentry_sdk/scrubber.py
@@ -59,19 +59,36 @@
 
 
 class EventScrubber(object):
-    def __init__(self, denylist=None):
-        # type: (Optional[List[str]]) -> None
+    def __init__(self, denylist=None, recursive=False):
+        # type: (Optional[List[str]], bool) -> None
         self.denylist = DEFAULT_DENYLIST if denylist is None else denylist
         self.denylist = [x.lower() for x in self.denylist]
+        self.recursive = recursive
+
+    def scrub_list(self, lst):
+        # type: (List[Any]) -> None
+        if not isinstance(lst, list):
+            return
+
+        for v in lst:
+            if isinstance(v, dict):
+                self.scrub_dict(v)
+            elif isinstance(v, list):
+                self.scrub_list(v)
 
     def scrub_dict(self, d):
         # type: (Dict[str, Any]) -> None
         if not isinstance(d, dict):
             return
 
-        for k in d.keys():
+        for k, v in d.items():
             if isinstance(k, string_types) and k.lower() in self.denylist:
                 d[k] = AnnotatedValue.substituted_because_contains_sensitive_data()
+            elif self.recursive:
+                if isinstance(v, dict):
+                    self.scrub_dict(v)
+                elif isinstance(v, list):
+                    self.scrub_list(v)
 
     def scrub_request(self, event):
         # type: (Event) -> None


Test patch:
diff --git a/tests/test_scrubber.py b/tests/test_scrubber.py
--- a/tests/test_scrubber.py
+++ b/tests/test_scrubber.py
@@ -169,3 +169,18 @@ def test_scrubbing_doesnt_affect_local_vars(sentry_init, capture_events):
     (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]'"