|
| 1 | +"""Sentry SDK integration: scrub PII from events before they leave the process. |
| 2 | +
|
| 3 | +Usage:: |
| 4 | +
|
| 5 | + import sentry_sdk |
| 6 | + from datafog.integrations.sentry import DataFogSentryIntegration |
| 7 | +
|
| 8 | + sentry_sdk.init( |
| 9 | + dsn="...", |
| 10 | + integrations=[DataFogSentryIntegration()], |
| 11 | + ) |
| 12 | +
|
| 13 | +Or wire the scrubber into ``before_send`` directly:: |
| 14 | +
|
| 15 | + sentry_sdk.init(dsn="...", before_send=scrub_event) |
| 16 | +
|
| 17 | +Behavior: |
| 18 | +
|
| 19 | +- The integration registers a *global event processor*, so it scrubs both |
| 20 | + error events and transactions (span descriptions and data ride inside |
| 21 | + transaction events) while leaving the single ``before_send`` slot free |
| 22 | + for the application's own callback. |
| 23 | +- Scrubbing is content-based: every string value in the event — messages, |
| 24 | + exception values, stack-frame local variables, breadcrumbs, request |
| 25 | + data, ``extra``, tags, span descriptions — is scanned and findings are |
| 26 | + replaced with ``[TYPE_N]`` tokens. This catches PII that Sentry's |
| 27 | + built-in ``EventScrubber`` misses, because that scrubber only matches |
| 28 | + sensitive *key names* and never inspects values. |
| 29 | +- ``fail_policy`` — ``open`` (default) sends the event unscrubbed if the |
| 30 | + engine errors, so a scrubber bug never costs you crash reports; |
| 31 | + ``closed`` drops the event instead, for compliance deployments where |
| 32 | + unscanned egress is worse than a missing event. |
| 33 | +
|
| 34 | +Errors report entity type counts only — matched PII is never echoed into |
| 35 | +logs or exceptions. |
| 36 | +
|
| 37 | +Requires ``sentry-sdk`` >= 2.0 (this module is not imported by ``datafog`` |
| 38 | +core; install with ``pip install datafog[sentry]`` or alongside your app's |
| 39 | +existing sentry-sdk). |
| 40 | +""" |
| 41 | + |
| 42 | +import logging |
| 43 | +from typing import Any, Optional |
| 44 | + |
| 45 | +import sentry_sdk |
| 46 | +from sentry_sdk.integrations import Integration |
| 47 | + |
| 48 | +# High-precision defaults, matching the LiteLLM guardrail and Claude Code |
| 49 | +# hook adapters: noisy-in-practice types (IP_ADDRESS, DOB, ZIP) must be |
| 50 | +# opted into explicitly. |
| 51 | +DEFAULT_ENTITY_TYPES = ["EMAIL", "PHONE", "CREDIT_CARD", "SSN"] |
| 52 | + |
| 53 | +VALID_FAIL_POLICIES = {"open", "closed"} |
| 54 | + |
| 55 | +# Per-string and per-event scan caps, mirroring the Claude Code hook: a |
| 56 | +# pathological payload (huge repr in a frame var, giant request body) must |
| 57 | +# not stall event delivery. Text beyond a cap is REPLACED with an |
| 58 | +# [UNSCANNED_N_CHARS] marker, never sent raw: emitting an unscanned tail |
| 59 | +# would let a PII value that straddles the cut point reassemble unredacted, |
| 60 | +# and unscanned egress is the one thing this integration must not do. Both |
| 61 | +# caps sit far above Sentry's own event-size limits, so real events never |
| 62 | +# reach them. |
| 63 | +MAX_SCAN_CHARS = 1_000_000 |
| 64 | +TOTAL_SCAN_CHARS = 8_000_000 |
| 65 | + |
| 66 | +# Top-level event fields that are machine identifiers, not free text. |
| 67 | +# Skipping them avoids mangling values that only *look* like PII (a release |
| 68 | +# tag containing an @, a server hostname) and matches what Sentry's own |
| 69 | +# EventScrubber leaves alone. The skip applies at the event's top level |
| 70 | +# only — a nested key that happens to be named "release" (e.g. inside |
| 71 | +# ``extra``) is still scanned. |
| 72 | +_SKIP_TOP_LEVEL_KEYS = frozenset( |
| 73 | + { |
| 74 | + "event_id", |
| 75 | + "timestamp", |
| 76 | + "start_timestamp", |
| 77 | + "received", |
| 78 | + "platform", |
| 79 | + "sdk", |
| 80 | + "modules", |
| 81 | + "release", |
| 82 | + "dist", |
| 83 | + "environment", |
| 84 | + "server_name", |
| 85 | + "type", |
| 86 | + "level", |
| 87 | + } |
| 88 | +) |
| 89 | + |
| 90 | +logger = logging.getLogger(__name__) |
| 91 | + |
| 92 | + |
| 93 | +class _EventScrubber: |
| 94 | + """Single-event scrub pass: walks the event, redacts string values. |
| 95 | +
|
| 96 | + Mutates containers in place — deliberate: the Sentry processor contract |
| 97 | + is mutate-and-return, and deep-copying every event would double memory |
| 98 | + while leaving an unredacted copy other processors could still see. |
| 99 | +
|
| 100 | + A fresh instance is created per event (see ``_scrub_with_policy``), so |
| 101 | + ``counts``, ``_budget``, and ``_seen`` are never shared across events or |
| 102 | + threads. Do not hoist construction out of the per-event path: a reused |
| 103 | + instance would leak budget and alias state between events. |
| 104 | + """ |
| 105 | + |
| 106 | + def __init__( |
| 107 | + self, |
| 108 | + entity_types: list[str], |
| 109 | + allowlist: Optional[list[str]], |
| 110 | + allowlist_patterns: Optional[list[str]], |
| 111 | + ) -> None: |
| 112 | + self.entity_types = entity_types |
| 113 | + self.allowlist = allowlist |
| 114 | + self.allowlist_patterns = allowlist_patterns |
| 115 | + self.counts: dict[str, int] = {} |
| 116 | + self.unscanned_chars = 0 |
| 117 | + self._budget = TOTAL_SCAN_CHARS |
| 118 | + self._seen: set[int] = set() |
| 119 | + |
| 120 | + def _unscanned(self, length: int) -> str: |
| 121 | + self.unscanned_chars += length |
| 122 | + return f"[UNSCANNED_{length}_CHARS]" |
| 123 | + |
| 124 | + def _redact_string(self, text: str) -> str: |
| 125 | + limit = min(MAX_SCAN_CHARS, self._budget) |
| 126 | + if limit <= 0: |
| 127 | + return self._unscanned(len(text)) |
| 128 | + import datafog |
| 129 | + |
| 130 | + chunk = text[:limit] |
| 131 | + self._budget -= len(chunk) |
| 132 | + result = datafog.redact( |
| 133 | + chunk, |
| 134 | + engine="regex", |
| 135 | + entity_types=self.entity_types, |
| 136 | + allowlist=self.allowlist, |
| 137 | + allowlist_patterns=self.allowlist_patterns, |
| 138 | + ) |
| 139 | + for entity in result.entities: |
| 140 | + self.counts[entity.type] = self.counts.get(entity.type, 0) + 1 |
| 141 | + tail = text[len(chunk) :] |
| 142 | + if tail: |
| 143 | + # Never reattach the unscanned tail raw: a PII value straddling |
| 144 | + # the cut point would fail to match in the truncated chunk and |
| 145 | + # reassemble unredacted in the output. |
| 146 | + return result.redacted_text + self._unscanned(len(tail)) |
| 147 | + return result.redacted_text if result.entities else text |
| 148 | + |
| 149 | + def _transform(self, value: Any, stack: list[Any]) -> Any: |
| 150 | + """Redact a string, or queue a container for walking. |
| 151 | +
|
| 152 | + Tuples and sets are rebuilt as lists — the Sentry serializer emits |
| 153 | + all three as JSON arrays, and a list can be walked and mutated in |
| 154 | + place. ``_seen`` dedupes aliased containers, so a structure reachable |
| 155 | + from two places is scanned once (double-scanning would double-spend |
| 156 | + the budget) and cyclic structures terminate. Other value types |
| 157 | + (numbers, custom objects) pass through untouched: the SDK reprs |
| 158 | + custom objects only after processors run, so their contents are |
| 159 | + out of reach here. |
| 160 | + """ |
| 161 | + if isinstance(value, str): |
| 162 | + return self._redact_string(value) |
| 163 | + if isinstance(value, (tuple, set, frozenset)): |
| 164 | + value = list(value) |
| 165 | + if isinstance(value, (dict, list)) and id(value) not in self._seen: |
| 166 | + self._seen.add(id(value)) |
| 167 | + stack.append(value) |
| 168 | + return value |
| 169 | + |
| 170 | + def scrub(self, event: dict) -> dict: |
| 171 | + """Redact every string value in the event. |
| 172 | +
|
| 173 | + Iterative (explicit stack), so adversarially deep structures in |
| 174 | + ``extra`` or frame vars cannot trigger ``RecursionError`` and |
| 175 | + silently skip the scan. Dict *keys* are left alone, matching |
| 176 | + Sentry's EventScrubber. |
| 177 | + """ |
| 178 | + stack: list[Any] = [] |
| 179 | + for key in list(event.keys()): |
| 180 | + if key in _SKIP_TOP_LEVEL_KEYS: |
| 181 | + continue |
| 182 | + new_value = self._transform(event[key], stack) |
| 183 | + if new_value is not event[key]: |
| 184 | + event[key] = new_value |
| 185 | + while stack: |
| 186 | + current = stack.pop() |
| 187 | + if isinstance(current, dict): |
| 188 | + for key in list(current.keys()): |
| 189 | + new_value = self._transform(current[key], stack) |
| 190 | + if new_value is not current[key]: |
| 191 | + current[key] = new_value |
| 192 | + else: # list — _transform only queues dicts and lists |
| 193 | + for index, value in enumerate(current): |
| 194 | + new_value = self._transform(value, stack) |
| 195 | + if new_value is not value: |
| 196 | + current[index] = new_value |
| 197 | + if self.unscanned_chars: |
| 198 | + logger.debug( |
| 199 | + "DataFog Sentry scrubber: %d chars beyond scan caps replaced " |
| 200 | + "with [UNSCANNED_N_CHARS] markers", |
| 201 | + self.unscanned_chars, |
| 202 | + ) |
| 203 | + return event |
| 204 | + |
| 205 | + |
| 206 | +def _summary(counts: dict[str, int]) -> str: |
| 207 | + return ", ".join(f"{etype} x{n}" for etype, n in sorted(counts.items())) |
| 208 | + |
| 209 | + |
| 210 | +def _scrub_with_policy( |
| 211 | + event: dict, |
| 212 | + entity_types: list[str], |
| 213 | + allowlist: Optional[list[str]], |
| 214 | + allowlist_patterns: Optional[list[str]], |
| 215 | + fail_policy: str, |
| 216 | +) -> Optional[dict]: |
| 217 | + scrubber = _EventScrubber(entity_types, allowlist, allowlist_patterns) |
| 218 | + try: |
| 219 | + scrubbed = scrubber.scrub(event) |
| 220 | + except Exception as exc: # noqa: BLE001 — fail policy decides |
| 221 | + # Only the exception *type* is ever logged. Engine exception |
| 222 | + # messages can embed the text being scanned, so interpolating |
| 223 | + # str(exc) would leak the PII this integration exists to contain. |
| 224 | + if fail_policy == "closed": |
| 225 | + logger.warning( |
| 226 | + "DataFog Sentry scrubber failed and fail_policy is 'closed'; " |
| 227 | + "dropping event (%s).", |
| 228 | + type(exc).__name__, |
| 229 | + ) |
| 230 | + return None |
| 231 | + logger.warning( |
| 232 | + "DataFog Sentry scrubber error (fail-open, event sent unscanned " |
| 233 | + "beyond partial redaction): %s", |
| 234 | + type(exc).__name__, |
| 235 | + ) |
| 236 | + return event |
| 237 | + if scrubber.counts: |
| 238 | + logger.debug("DataFog Sentry scrubber redacted: %s", _summary(scrubber.counts)) |
| 239 | + return scrubbed |
| 240 | + |
| 241 | + |
| 242 | +def scrub_event( |
| 243 | + event: dict, |
| 244 | + hint: Optional[dict] = None, |
| 245 | + *, |
| 246 | + entity_types: Optional[list[str]] = None, |
| 247 | + allowlist: Optional[list[str]] = None, |
| 248 | + allowlist_patterns: Optional[list[str]] = None, |
| 249 | + fail_policy: str = "open", |
| 250 | +) -> Optional[dict]: |
| 251 | + """Scrub a Sentry event dict; drop-in ``before_send`` callback. |
| 252 | +
|
| 253 | + The two positional parameters match Sentry's ``before_send(event, hint)`` |
| 254 | + signature so the function can be passed directly. The keyword-only |
| 255 | + options mirror :class:`DataFogSentryIntegration`; to preconfigure them, |
| 256 | + wrap in ``functools.partial`` or use the integration instead. |
| 257 | + """ |
| 258 | + if fail_policy not in VALID_FAIL_POLICIES: |
| 259 | + raise ValueError(f"fail_policy must be one of: {sorted(VALID_FAIL_POLICIES)}") |
| 260 | + return _scrub_with_policy( |
| 261 | + event, |
| 262 | + entity_types or DEFAULT_ENTITY_TYPES, |
| 263 | + allowlist, |
| 264 | + allowlist_patterns, |
| 265 | + fail_policy, |
| 266 | + ) |
| 267 | + |
| 268 | + |
| 269 | +class DataFogSentryIntegration(Integration): |
| 270 | + """Content-based PII scrubbing for the Sentry Python SDK.""" |
| 271 | + |
| 272 | + identifier = "datafog" |
| 273 | + |
| 274 | + def __init__( |
| 275 | + self, |
| 276 | + entity_types: Optional[list[str]] = None, |
| 277 | + allowlist: Optional[list[str]] = None, |
| 278 | + allowlist_patterns: Optional[list[str]] = None, |
| 279 | + fail_policy: str = "open", |
| 280 | + ) -> None: |
| 281 | + if fail_policy not in VALID_FAIL_POLICIES: |
| 282 | + raise ValueError( |
| 283 | + f"fail_policy must be one of: {sorted(VALID_FAIL_POLICIES)}" |
| 284 | + ) |
| 285 | + self.entity_types = entity_types or DEFAULT_ENTITY_TYPES |
| 286 | + self.allowlist = allowlist |
| 287 | + self.allowlist_patterns = allowlist_patterns |
| 288 | + self.fail_policy = fail_policy |
| 289 | + |
| 290 | + @staticmethod |
| 291 | + def setup_once() -> None: |
| 292 | + # Registered once per process. The processor resolves the integration |
| 293 | + # instance from the *current* client on every event, so config always |
| 294 | + # comes from the active ``sentry_sdk.init`` call and clients without |
| 295 | + # this integration are passed through untouched. |
| 296 | + from sentry_sdk.scope import add_global_event_processor |
| 297 | + |
| 298 | + add_global_event_processor(_global_processor) |
| 299 | + |
| 300 | + |
| 301 | +def _global_processor(event: dict, hint: Optional[dict]) -> Optional[dict]: |
| 302 | + try: |
| 303 | + integration = sentry_sdk.get_client().get_integration(DataFogSentryIntegration) |
| 304 | + except Exception as exc: # noqa: BLE001 — never break event delivery |
| 305 | + # The lookup runs outside _scrub_with_policy's error handling and no |
| 306 | + # integration (hence no fail_policy) is resolvable here, so the only |
| 307 | + # safe default is to pass the event through. |
| 308 | + logger.warning( |
| 309 | + "DataFog Sentry integration lookup failed (event passed " |
| 310 | + "through unscanned): %s", |
| 311 | + type(exc).__name__, |
| 312 | + ) |
| 313 | + return event |
| 314 | + if integration is None: |
| 315 | + return event |
| 316 | + return _scrub_with_policy( |
| 317 | + event, |
| 318 | + integration.entity_types, |
| 319 | + integration.allowlist, |
| 320 | + integration.allowlist_patterns, |
| 321 | + integration.fail_policy, |
| 322 | + ) |
0 commit comments