debugbundle-python 1.4.2__tar.gz → 1.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/PKG-INFO +7 -5
  2. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/README.md +3 -3
  3. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/pyproject.toml +4 -2
  4. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle/core.py +58 -13
  5. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle/event_support.py +1 -1
  6. debugbundle_python-1.5.0/src/debugbundle/redaction.py +226 -0
  7. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle/relay.py +8 -0
  8. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle_python.egg-info/PKG-INFO +7 -5
  9. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle_python.egg-info/SOURCES.txt +1 -0
  10. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle_python.egg-info/requires.txt +1 -1
  11. debugbundle_python-1.5.0/tests/test_privacy_conformance.py +34 -0
  12. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/tests/test_relay.py +11 -0
  13. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/tests/test_repository_metadata.py +1 -1
  14. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/tests/test_sdk.py +23 -0
  15. debugbundle_python-1.4.2/src/debugbundle/redaction.py +0 -29
  16. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/LICENSE +0 -0
  17. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/setup.cfg +0 -0
  18. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle/__init__.py +0 -0
  19. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle/acknowledgement.py +0 -0
  20. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle/before_send.py +0 -0
  21. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle/config.py +0 -0
  22. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle/integrations/__init__.py +0 -0
  23. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle/integrations/common.py +0 -0
  24. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle/integrations/django.py +0 -0
  25. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle/integrations/fastapi.py +0 -0
  26. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle/integrations/flask.py +0 -0
  27. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle/integrations/relay_django.py +0 -0
  28. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle/integrations/relay_fastapi.py +0 -0
  29. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle/integrations/relay_flask.py +0 -0
  30. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle/logger_integrations.py +0 -0
  31. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle/py.typed +0 -0
  32. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle/relay_delivery.py +0 -0
  33. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle/structlog_integration.py +0 -0
  34. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle/suppression.py +0 -0
  35. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle/transport.py +0 -0
  36. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle/trigger_token.py +0 -0
  37. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle_python.egg-info/dependency_links.txt +0 -0
  38. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/src/debugbundle_python.egg-info/top_level.txt +0 -0
  39. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/tests/test_before_send.py +0 -0
  40. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/tests/test_contracts.py +0 -0
  41. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/tests/test_framework_integrations.py +0 -0
  42. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/tests/test_hooks.py +0 -0
  43. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/tests/test_http_integration.py +0 -0
  44. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/tests/test_optional_imports.py +0 -0
  45. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/tests/test_relay_delivery.py +0 -0
  46. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/tests/test_remote_config.py +0 -0
  47. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/tests/test_schema_validation.py +0 -0
  48. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/tests/test_smoke_script.py +0 -0
  49. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/tests/test_structlog_filtering.py +0 -0
  50. {debugbundle_python-1.4.2 → debugbundle_python-1.5.0}/tests/test_trigger_token.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debugbundle-python
3
- Version: 1.4.2
3
+ Version: 1.5.0
4
4
  Summary: DebugBundle SDK for Python
5
5
  Author: DebugBundle
6
6
  License-Expression: Apache-2.0
@@ -17,6 +17,8 @@ Classifier: Programming Language :: Python :: 3 :: Only
17
17
  Classifier: Programming Language :: Python :: 3.10
18
18
  Classifier: Programming Language :: Python :: 3.11
19
19
  Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
20
22
  Classifier: Typing :: Typed
21
23
  Requires-Python: >=3.10
22
24
  Description-Content-Type: text/markdown
@@ -24,7 +26,7 @@ License-File: LICENSE
24
26
  Requires-Dist: httpx<0.29,>=0.27
25
27
  Provides-Extra: dev
26
28
  Requires-Dist: build<2,>=1; extra == "dev"
27
- Requires-Dist: django<6,>=5; extra == "dev"
29
+ Requires-Dist: django<7,>=5; extra == "dev"
28
30
  Requires-Dist: fastapi<1,>=0.115; extra == "dev"
29
31
  Requires-Dist: flask<4,>=3; extra == "dev"
30
32
  Requires-Dist: jsonschema<5,>=4.23; extra == "dev"
@@ -263,8 +265,8 @@ There is no zero-install fallback for the Python SDK itself in V1. The nearest l
263
265
 
264
266
  | Surface | Minimum compatibility version | Recommended production version | Installed-base compatibility lane | Rolling CI lane | Out of scope |
265
267
  | --- | --- | --- | --- | --- | --- |
266
- | Python runtime | 3.10 | 3.12 | 3.10 and 3.11 remain supported for installed-base coverage | 3.12 | 3.9 and older |
267
- | Django | 5.x | latest 5.x patch | 5.x compatibility support | repo release smoke and tests install Django 5.x | Django 4.x and older |
268
+ | Python runtime | 3.10 | 3.14 | 3.10 through 3.13 remain supported for installed-base coverage | 3.10 through 3.14 | 3.9 and older |
269
+ | Django | 5.x | latest 6.1 patch | 5.x compatibility support | Python 3.14 suite exercises Django 6.1 | Django 4.x and older |
268
270
  | Flask | 3.x | latest 3.x patch | 3.x compatibility support | repo release smoke installs Flask 3.x | Flask 2.x and older |
269
271
  | FastAPI | 0.115+ | latest 0.115+ patch line | 0.115+ compatibility support | repo tests install FastAPI 0.115+ | standalone Starlette, older FastAPI lines |
270
272
 
@@ -275,7 +277,7 @@ Post-V1 planned expansions from `spec/sdk-language-targets.md` remain out of sco
275
277
  `debugbundle-python` ships as one package in V1, so there is no multi-package version-alignment step like a BOM or plugin family lock.
276
278
 
277
279
  - Pin one `debugbundle-python` version across your service and worker repos when you want identical SDK behavior everywhere.
278
- - Keep framework dependencies inside the supported lanes above: Django 5.x, Flask 3.x, and FastAPI 0.115+.
280
+ - Keep framework dependencies inside the supported lanes above: Django 5.x or 6.x, Flask 3.x, and FastAPI 0.115+.
279
281
  - The packaged HTTP client dependency is `httpx>=0.27,<0.29`; if you override transport behavior in tests or wrappers, stay inside that range unless you retest the SDK.
280
282
 
281
283
  ## Safety Defaults
@@ -224,8 +224,8 @@ There is no zero-install fallback for the Python SDK itself in V1. The nearest l
224
224
 
225
225
  | Surface | Minimum compatibility version | Recommended production version | Installed-base compatibility lane | Rolling CI lane | Out of scope |
226
226
  | --- | --- | --- | --- | --- | --- |
227
- | Python runtime | 3.10 | 3.12 | 3.10 and 3.11 remain supported for installed-base coverage | 3.12 | 3.9 and older |
228
- | Django | 5.x | latest 5.x patch | 5.x compatibility support | repo release smoke and tests install Django 5.x | Django 4.x and older |
227
+ | Python runtime | 3.10 | 3.14 | 3.10 through 3.13 remain supported for installed-base coverage | 3.10 through 3.14 | 3.9 and older |
228
+ | Django | 5.x | latest 6.1 patch | 5.x compatibility support | Python 3.14 suite exercises Django 6.1 | Django 4.x and older |
229
229
  | Flask | 3.x | latest 3.x patch | 3.x compatibility support | repo release smoke installs Flask 3.x | Flask 2.x and older |
230
230
  | FastAPI | 0.115+ | latest 0.115+ patch line | 0.115+ compatibility support | repo tests install FastAPI 0.115+ | standalone Starlette, older FastAPI lines |
231
231
 
@@ -236,7 +236,7 @@ Post-V1 planned expansions from `spec/sdk-language-targets.md` remain out of sco
236
236
  `debugbundle-python` ships as one package in V1, so there is no multi-package version-alignment step like a BOM or plugin family lock.
237
237
 
238
238
  - Pin one `debugbundle-python` version across your service and worker repos when you want identical SDK behavior everywhere.
239
- - Keep framework dependencies inside the supported lanes above: Django 5.x, Flask 3.x, and FastAPI 0.115+.
239
+ - Keep framework dependencies inside the supported lanes above: Django 5.x or 6.x, Flask 3.x, and FastAPI 0.115+.
240
240
  - The packaged HTTP client dependency is `httpx>=0.27,<0.29`; if you override transport behavior in tests or wrappers, stay inside that range unless you retest the SDK.
241
241
 
242
242
  ## Safety Defaults
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "debugbundle-python"
7
- version = "1.4.2"
7
+ version = "1.5.0"
8
8
  description = "DebugBundle SDK for Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -21,6 +21,8 @@ classifiers = [
21
21
  "Programming Language :: Python :: 3.10",
22
22
  "Programming Language :: Python :: 3.11",
23
23
  "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
24
26
  "Typing :: Typed"
25
27
  ]
26
28
  dependencies = [
@@ -30,7 +32,7 @@ dependencies = [
30
32
  [project.optional-dependencies]
31
33
  dev = [
32
34
  "build>=1,<2",
33
- "django>=5,<6",
35
+ "django>=5,<7",
34
36
  "fastapi>=0.115,<1",
35
37
  "flask>=3,<4",
36
38
  "jsonschema>=4.23,<5",
@@ -45,7 +45,13 @@ from .event_support import (
45
45
  time_now,
46
46
  )
47
47
  from .logger_integrations import attach_optional_integrations
48
- from .redaction import DEFAULT_REDACT_FIELDS, redact_value
48
+ from .redaction import (
49
+ DEFAULT_REDACT_FIELDS,
50
+ UnsafeTelemetry,
51
+ has_safe_event_identity,
52
+ redact_value,
53
+ sanitize_telemetry,
54
+ )
49
55
  from .suppression import EventSuppressionTracker
50
56
  from .transport import HttpTransport, Transport, coerce_transport_response
51
57
  from .trigger_token import resolve_request_trigger_directives
@@ -322,7 +328,12 @@ class DebugBundleSdk:
322
328
 
323
329
  def set_context(self, key: str, value: object) -> None:
324
330
  with self._lock:
325
- self._context[key] = redact_value({key: value}, self._redact_fields)[key]
331
+ try:
332
+ protected = sanitize_telemetry({key: value}, self._redact_fields)
333
+ if isinstance(protected, dict):
334
+ self._context[key] = protected.get(key)
335
+ except UnsafeTelemetry:
336
+ return
326
337
 
327
338
  def _bind_scoped_context(self, context: Mapping[str, object]) -> Token[dict[str, object] | None]:
328
339
  scoped_context = dict(self._scoped_context.get() or {})
@@ -330,7 +341,11 @@ class DebugBundleSdk:
330
341
  if value is None:
331
342
  continue
332
343
  scoped_context[str(key)] = value
333
- return self._scoped_context.set(cast(dict[str, object], redact_value(scoped_context, self._redact_fields)))
344
+ try:
345
+ protected = sanitize_telemetry(scoped_context, self._redact_fields)
346
+ return self._scoped_context.set(cast(dict[str, object], protected))
347
+ except UnsafeTelemetry:
348
+ return self._scoped_context.set({})
334
349
 
335
350
  def _reset_scoped_context(self, token: Token[dict[str, object] | None]) -> None:
336
351
  self._scoped_context.reset(token)
@@ -433,11 +448,17 @@ class DebugBundleSdk:
433
448
  if label not in self._probe_buffers and len(self._probe_buffers) >= self._max_probe_labels:
434
449
  return
435
450
 
436
- value = data() if callable(data) else data
437
- if not isinstance(value, Mapping):
438
- value = {"value": value}
439
-
440
- redacted_value = redact_mapping(dict(value), self._redact_fields)
451
+ try:
452
+ protected_label = sanitize_telemetry(label, self._redact_fields)
453
+ value = data() if callable(data) else data
454
+ if not isinstance(value, Mapping):
455
+ value = {"value": value}
456
+ redacted_value = sanitize_telemetry(dict(value), self._redact_fields)
457
+ if not isinstance(protected_label, str) or not isinstance(redacted_value, dict):
458
+ return
459
+ label = protected_label
460
+ except Exception:
461
+ return
441
462
 
442
463
  if is_heavy:
443
464
  self._emit_probe_events(label, redacted_value, matching_directives)
@@ -613,8 +634,23 @@ class DebugBundleSdk:
613
634
  merged.update({str(key): value for key, value in context.items()})
614
635
  return cast(dict[str, object], redact_value(merged, self._redact_fields))
615
636
 
637
+ def _protect_event(self, event: dict[str, object]) -> dict[str, object] | None:
638
+ try:
639
+ if not has_safe_event_identity(event, self._redact_fields):
640
+ return None
641
+ candidate = dict(event)
642
+ for key in ("payload", "context", "service"):
643
+ if key in candidate:
644
+ candidate[key] = sanitize_telemetry(candidate[key], self._redact_fields)
645
+ return candidate
646
+ except UnsafeTelemetry:
647
+ return None
648
+
616
649
  def _enqueue_event(self, event: dict[str, object]) -> None:
617
- self._buffer.append(event)
650
+ protected = self._protect_event(event)
651
+ if protected is None:
652
+ return
653
+ self._buffer.append(protected)
618
654
  if len(self._buffer) >= self._batch_size:
619
655
  self.flush()
620
656
  return
@@ -638,14 +674,20 @@ class DebugBundleSdk:
638
674
  aggregate.update(self._base_event(event_type, cast(dict[str, object], payload)))
639
675
  prepared = self._apply_before_send_event(aggregate)
640
676
  if prepared is not None:
641
- self._buffer.append(prepared)
677
+ protected = self._protect_event(prepared)
678
+ if protected is not None:
679
+ self._buffer.append(protected)
642
680
 
643
681
  def _apply_before_send_event(self, event: dict[str, object]) -> dict[str, object] | None:
644
- return apply_before_send(
645
- event,
682
+ protected = self._protect_event(event)
683
+ if protected is None:
684
+ return None
685
+ result = apply_before_send(
686
+ protected,
646
687
  self._before_send,
647
688
  lambda code, message: self._emit_diagnostic(code, message),
648
689
  )
690
+ return self._protect_event(result) if result is not None else None
649
691
 
650
692
  def _build_probe_data(self) -> dict[str, object] | None:
651
693
  items: list[dict[str, object]] = []
@@ -780,7 +822,10 @@ class DebugBundleSdk:
780
822
  return
781
823
  diagnostic: dict[str, object] = {"code": code, "message": message}
782
824
  if metadata is not None:
783
- diagnostic["metadata"] = metadata
825
+ try:
826
+ diagnostic["metadata"] = sanitize_telemetry(metadata, self._redact_fields)
827
+ except UnsafeTelemetry:
828
+ pass
784
829
  try:
785
830
  self._on_diagnostic(diagnostic)
786
831
  except Exception:
@@ -264,7 +264,7 @@ def sdk_version() -> str:
264
264
  try:
265
265
  return metadata.version("debugbundle-python")
266
266
  except metadata.PackageNotFoundError:
267
- return "1.4.2"
267
+ return "1.5.0"
268
268
 
269
269
 
270
270
  def sdk_config_endpoint(events_endpoint: str) -> str:
@@ -0,0 +1,226 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from collections.abc import Mapping, Sequence
6
+ from typing import Any
7
+ from urllib.parse import parse_qsl, unquote, urlencode, urlsplit, urlunsplit
8
+
9
+ DEFAULT_REDACT_FIELDS = {
10
+ "authorization",
11
+ "cookie",
12
+ "credit_card",
13
+ "password",
14
+ "secret",
15
+ "ssn",
16
+ "token",
17
+ }
18
+
19
+ REDACTED_VALUE = "[REDACTED]"
20
+ MANDATORY_FIELDS = DEFAULT_REDACT_FIELDS | {
21
+ "api_key", "apikey", "access_token", "refresh_token", "private_key", "bearer", "session_id",
22
+ "passwd", "card_number", "cvv", "cvc", "pin", "expiry", "phone", "otp", "verification_code",
23
+ }
24
+ _HEADER = re.compile(r"\b(Authorization|Proxy-Authorization|Cookie|Set-Cookie)\s*:\s*[^\r\n]*", re.I)
25
+ _BEARER = re.compile(r"\b(Bearer|Basic)\s+[A-Za-z0-9._~+/-]{6,}", re.I)
26
+ _TOKEN = re.compile(r"\bdbundle_(?:proj|mem|probe|agent)_[A-Za-z0-9_-]+\b")
27
+ _PEM = re.compile(
28
+ r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----",
29
+ re.I,
30
+ )
31
+ _PEM_START = re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----", re.I)
32
+ _CARD = re.compile(r"(?<![A-Za-z0-9_-])(?:\d[ -]?){12,18}\d(?![A-Za-z0-9_-])")
33
+ _URL = re.compile(r"\bhttps?://[^\s<>\"']+", re.I)
34
+ _LABELS = MANDATORY_FIELDS | {"client_secret", "x_api_key", "accessToken", "refreshToken", "privateKey", "clientSecret"}
35
+ _ASSIGNMENT = re.compile(
36
+ r"\b(" + "|".join(re.escape(key) for key in sorted(_LABELS, key=len, reverse=True)) +
37
+ r")\b([\"']?\s*[:=]\s*)(?:\"[^\"]*\"|'[^']*'|[^\s&,;]+)", re.I,
38
+ )
39
+
40
+
41
+ class UnsafeTelemetry(ValueError):
42
+ """A fixed safe failure marker; never includes caller content."""
43
+
44
+
45
+ def _sensitive_key(key: str, fields: set[str]) -> bool:
46
+ normalized = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", key.strip()).lower()
47
+ segments = [segment for segment in re.split(r"[^a-z0-9]+", normalized) if segment]
48
+ canonical = {re.sub(r"[^a-z0-9]", "", field.lower()) for field in fields}
49
+ for start in range(len(segments)):
50
+ combined = ""
51
+ for segment in segments[start:]:
52
+ combined += segment
53
+ if combined in canonical:
54
+ return True
55
+ return False
56
+
57
+
58
+ def _luhn(candidate: str) -> bool:
59
+ digits = re.sub(r"[^0-9]", "", candidate)
60
+ if not 13 <= len(digits) <= 19 or len(set(digits)) == 1:
61
+ return False
62
+ total = 0
63
+ for index, char in enumerate(reversed(digits)):
64
+ digit = int(char)
65
+ if index % 2:
66
+ digit *= 2
67
+ if digit > 9:
68
+ digit -= 9
69
+ total += digit
70
+ return total % 10 == 0
71
+
72
+
73
+ def _scrub_url(raw: str, fields: set[str]) -> str:
74
+ try:
75
+ parsed = urlsplit(raw)
76
+ if not parsed.scheme or not parsed.hostname:
77
+ raise ValueError("invalid_url")
78
+ _ = parsed.port # Invalid ports make the entire URL unscannable.
79
+ host = parsed.netloc.rsplit("@", 1)[-1]
80
+ authority = f"REDACTED@{host}" if parsed.username or parsed.password else host
81
+ query = urlencode([
82
+ (key, REDACTED_VALUE if _sensitive_key(key, fields)
83
+ or _scrub_text(value, fields, False) != value else value)
84
+ for key, value in parse_qsl(parsed.query, keep_blank_values=True)
85
+ if len(key) <= 128 and _scrub_text(key, fields, False) == key
86
+ ])
87
+ return urlunsplit((parsed.scheme.lower(), authority, parsed.path or "/", query, ""))
88
+ except ValueError:
89
+ return REDACTED_VALUE
90
+
91
+
92
+ def _scrub_text(value: str, extra: set[str], scan_urls: bool = True) -> str:
93
+ if _PEM_START.search(value) and not _PEM.search(value):
94
+ return REDACTED_VALUE
95
+ if re.search(r"(?:password|token|secret|authorization|cookie)%3[ad]", value, re.I):
96
+ try:
97
+ value = unquote(value, errors="strict")
98
+ except UnicodeError:
99
+ return REDACTED_VALUE
100
+ result = _PEM.sub(REDACTED_VALUE, value)
101
+ result = _HEADER.sub(lambda match: f"{match.group(1)}: {REDACTED_VALUE}", result)
102
+ result = _BEARER.sub(lambda match: f"{match.group(1)} {REDACTED_VALUE}", result)
103
+ result = _TOKEN.sub(REDACTED_VALUE, result)
104
+ result = _ASSIGNMENT.sub(lambda match: f"{match.group(1)}{match.group(2)}{REDACTED_VALUE}", result)
105
+ for field in extra:
106
+ named = re.compile(r"\b(" + re.escape(field) + r")\b([\"']?\s*[:=]\s*)(?:\"[^\"]*\"|'[^']*'|[^\s&,;]+)", re.I)
107
+ result = named.sub(lambda match: f"{match.group(1)}{match.group(2)}{REDACTED_VALUE}", result)
108
+ result = _CARD.sub(lambda match: REDACTED_VALUE if _luhn(match.group(0)) else match.group(0), result)
109
+
110
+ if not scan_urls:
111
+ return result
112
+
113
+ def replace_url(match: re.Match[str]) -> str:
114
+ raw = match.group(0)
115
+ suffix = re.search(r"[).,;]+$", raw)
116
+ trailing = suffix.group(0) if suffix else ""
117
+ return _scrub_url(raw[:len(raw) - len(trailing)] if trailing else raw, MANDATORY_FIELDS | extra) + trailing
118
+
119
+ return _URL.sub(replace_url, result)
120
+
121
+
122
+ def sanitize_telemetry(value: Any, additional_fields: set[str] | None = None) -> Any:
123
+ """Mandatory bounded telemetry policy. Failure withholds the input, never returns its original."""
124
+ extra = additional_fields or set()
125
+ if len(extra) > 128 or any(not isinstance(field, str) or not 0 < len(field) <= 64 for field in extra):
126
+ raise UnsafeTelemetry("unsafe_input")
127
+ state = {"nodes": 0, "bytes": 0}
128
+ seen: set[int] = set()
129
+ fields = MANDATORY_FIELDS | extra
130
+
131
+ def visit(candidate: Any, depth: int, structured: bool) -> Any:
132
+ state["nodes"] += 1
133
+ if state["nodes"] > 4096:
134
+ raise UnsafeTelemetry("budget_exceeded")
135
+ if depth > 16:
136
+ return REDACTED_VALUE
137
+ if isinstance(candidate, str):
138
+ if len(candidate) > 16 * 1024:
139
+ return REDACTED_VALUE
140
+ try:
141
+ length = len(candidate.encode("utf-8"))
142
+ except UnicodeError as error:
143
+ raise UnsafeTelemetry("unsafe_input") from error
144
+ if length > 16 * 1024:
145
+ return REDACTED_VALUE
146
+ state["bytes"] += length
147
+ if state["bytes"] > 256 * 1024:
148
+ raise UnsafeTelemetry("budget_exceeded")
149
+ if structured and candidate.startswith(("{", "[")):
150
+ try:
151
+ nested = json.loads(candidate)
152
+ except (ValueError, TypeError):
153
+ nested = None
154
+ if isinstance(nested, (dict, list)):
155
+ return json.dumps(visit(nested, 0, False), separators=(",", ":"), ensure_ascii=False)
156
+ result = _scrub_text(candidate, extra)
157
+ if candidate.startswith(("{", "[")) and result == candidate and re.search(
158
+ r"(?:password|token|secret|authorization|cookie)[\"']?\s*[:=]", candidate, re.I
159
+ ):
160
+ return REDACTED_VALUE
161
+ return result
162
+ if candidate is None or isinstance(candidate, (bool, int, float)):
163
+ return candidate
164
+ identity = id(candidate)
165
+ if identity in seen:
166
+ return "[Circular]"
167
+ seen.add(identity)
168
+ try:
169
+ if isinstance(candidate, (list, tuple)):
170
+ if len(candidate) > 256:
171
+ return REDACTED_VALUE
172
+ return [visit(item, depth + 1, structured) for item in candidate]
173
+ if isinstance(candidate, Mapping):
174
+ if len(candidate) > 256:
175
+ return REDACTED_VALUE
176
+ output: dict[str, Any] = {}
177
+ for key, nested in candidate.items():
178
+ if not isinstance(key, str):
179
+ raise UnsafeTelemetry("unsafe_input")
180
+ if len(key) > 128:
181
+ continue
182
+ state["bytes"] += len(key.encode("utf-8"))
183
+ if state["bytes"] > 256 * 1024:
184
+ raise UnsafeTelemetry("budget_exceeded")
185
+ if _scrub_text(key, extra) != key:
186
+ continue
187
+ output[key] = (
188
+ REDACTED_VALUE if _sensitive_key(key, fields) else visit(nested, depth + 1, structured)
189
+ )
190
+ return output
191
+ raise UnsafeTelemetry("unsafe_input")
192
+ finally:
193
+ seen.remove(identity)
194
+
195
+ try:
196
+ sanitized = visit(value, 0, True)
197
+ if len(json.dumps(sanitized, separators=(",", ":"), ensure_ascii=False).encode("utf-8")) > 256 * 1024:
198
+ raise UnsafeTelemetry("budget_exceeded")
199
+ return sanitized
200
+ except UnsafeTelemetry:
201
+ raise
202
+ except (TypeError, ValueError, UnicodeError, RuntimeError, OverflowError) as error:
203
+ raise UnsafeTelemetry("unsafe_input") from error
204
+
205
+
206
+ def has_safe_event_identity(event: Mapping[str, Any], extra: set[str] | None = None) -> bool:
207
+ correlation = event.get("correlation") or {}
208
+ if not isinstance(correlation, dict) or len(correlation) > 8:
209
+ return False
210
+ values = [event.get(key) for key in ("schema_version", "sdk_name", "sdk_version")]
211
+ values.extend(correlation.values())
212
+ return all(value is None or isinstance(value, str)
213
+ and sanitize_telemetry(value, extra) == value for value in values)
214
+
215
+
216
+ def redact_value(value: Any, redact_fields: set[str]) -> Any:
217
+ if isinstance(value, Mapping):
218
+ return {
219
+ key: REDACTED_VALUE if key.lower() in redact_fields else redact_value(nested_value, redact_fields)
220
+ for key, nested_value in value.items()
221
+ }
222
+
223
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
224
+ return [redact_value(item, redact_fields) for item in value]
225
+
226
+ return value
@@ -6,6 +6,7 @@ from collections.abc import Callable, Mapping
6
6
  from dataclasses import dataclass, field
7
7
  from typing import Any
8
8
 
9
+ from .redaction import UnsafeTelemetry, has_safe_event_identity, sanitize_telemetry
9
10
  from .relay_delivery import (
10
11
  AtomicRelayFileTransport,
11
12
  RelayForwardTransport,
@@ -392,4 +393,11 @@ def _sanitize_event(
392
393
  if normalized_correlation:
393
394
  sanitized["correlation"] = normalized_correlation
394
395
 
396
+ try:
397
+ if not has_safe_event_identity(sanitized):
398
+ return None
399
+ sanitized["payload"] = sanitize_telemetry(sanitized["payload"])
400
+ sanitized["service"] = sanitize_telemetry(sanitized["service"])
401
+ except UnsafeTelemetry:
402
+ return None
395
403
  return sanitized
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debugbundle-python
3
- Version: 1.4.2
3
+ Version: 1.5.0
4
4
  Summary: DebugBundle SDK for Python
5
5
  Author: DebugBundle
6
6
  License-Expression: Apache-2.0
@@ -17,6 +17,8 @@ Classifier: Programming Language :: Python :: 3 :: Only
17
17
  Classifier: Programming Language :: Python :: 3.10
18
18
  Classifier: Programming Language :: Python :: 3.11
19
19
  Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
20
22
  Classifier: Typing :: Typed
21
23
  Requires-Python: >=3.10
22
24
  Description-Content-Type: text/markdown
@@ -24,7 +26,7 @@ License-File: LICENSE
24
26
  Requires-Dist: httpx<0.29,>=0.27
25
27
  Provides-Extra: dev
26
28
  Requires-Dist: build<2,>=1; extra == "dev"
27
- Requires-Dist: django<6,>=5; extra == "dev"
29
+ Requires-Dist: django<7,>=5; extra == "dev"
28
30
  Requires-Dist: fastapi<1,>=0.115; extra == "dev"
29
31
  Requires-Dist: flask<4,>=3; extra == "dev"
30
32
  Requires-Dist: jsonschema<5,>=4.23; extra == "dev"
@@ -263,8 +265,8 @@ There is no zero-install fallback for the Python SDK itself in V1. The nearest l
263
265
 
264
266
  | Surface | Minimum compatibility version | Recommended production version | Installed-base compatibility lane | Rolling CI lane | Out of scope |
265
267
  | --- | --- | --- | --- | --- | --- |
266
- | Python runtime | 3.10 | 3.12 | 3.10 and 3.11 remain supported for installed-base coverage | 3.12 | 3.9 and older |
267
- | Django | 5.x | latest 5.x patch | 5.x compatibility support | repo release smoke and tests install Django 5.x | Django 4.x and older |
268
+ | Python runtime | 3.10 | 3.14 | 3.10 through 3.13 remain supported for installed-base coverage | 3.10 through 3.14 | 3.9 and older |
269
+ | Django | 5.x | latest 6.1 patch | 5.x compatibility support | Python 3.14 suite exercises Django 6.1 | Django 4.x and older |
268
270
  | Flask | 3.x | latest 3.x patch | 3.x compatibility support | repo release smoke installs Flask 3.x | Flask 2.x and older |
269
271
  | FastAPI | 0.115+ | latest 0.115+ patch line | 0.115+ compatibility support | repo tests install FastAPI 0.115+ | standalone Starlette, older FastAPI lines |
270
272
 
@@ -275,7 +277,7 @@ Post-V1 planned expansions from `spec/sdk-language-targets.md` remain out of sco
275
277
  `debugbundle-python` ships as one package in V1, so there is no multi-package version-alignment step like a BOM or plugin family lock.
276
278
 
277
279
  - Pin one `debugbundle-python` version across your service and worker repos when you want identical SDK behavior everywhere.
278
- - Keep framework dependencies inside the supported lanes above: Django 5.x, Flask 3.x, and FastAPI 0.115+.
280
+ - Keep framework dependencies inside the supported lanes above: Django 5.x or 6.x, Flask 3.x, and FastAPI 0.115+.
279
281
  - The packaged HTTP client dependency is `httpx>=0.27,<0.29`; if you override transport behavior in tests or wrappers, stay inside that range unless you retest the SDK.
280
282
 
281
283
  ## Safety Defaults
@@ -35,6 +35,7 @@ tests/test_framework_integrations.py
35
35
  tests/test_hooks.py
36
36
  tests/test_http_integration.py
37
37
  tests/test_optional_imports.py
38
+ tests/test_privacy_conformance.py
38
39
  tests/test_relay.py
39
40
  tests/test_relay_delivery.py
40
41
  tests/test_remote_config.py
@@ -2,7 +2,7 @@ httpx<0.29,>=0.27
2
2
 
3
3
  [dev]
4
4
  build<2,>=1
5
- django<6,>=5
5
+ django<7,>=5
6
6
  fastapi<1,>=0.115
7
7
  flask<4,>=3
8
8
  jsonschema<5,>=4.23
@@ -0,0 +1,34 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from debugbundle.redaction import UnsafeTelemetry, has_safe_event_identity, sanitize_telemetry
5
+
6
+ CORPUS = json.loads((Path(__file__).parent / "fixtures" / "privacy-conformance.json").read_text())
7
+
8
+
9
+ def test_policy_corpus() -> None:
10
+ assert CORPUS["policy"] == "telemetry-privacy-v1"
11
+ for case in CORPUS["cases"]:
12
+ original = json.dumps(case["input"])
13
+ result = sanitize_telemetry(case["input"])
14
+ assert result == case["expected"], case["id"]
15
+ assert json.dumps(case["input"]) == original, case["id"]
16
+ assert sanitize_telemetry(result) == result, case["id"]
17
+
18
+
19
+ def test_additive_baseline_and_failure() -> None:
20
+ assert sanitize_telemetry({"password": "secret", "businessField": 42}, {"businessField"}) == {
21
+ "password": "[REDACTED]", "businessField": "[REDACTED]",
22
+ }
23
+ try:
24
+ sanitize_telemetry({"many": {f"key_{i}": [0] * 20 for i in range(220)}})
25
+ except UnsafeTelemetry as error:
26
+ assert str(error) == "budget_exceeded"
27
+ else:
28
+ raise AssertionError("unscannable input must fail closed")
29
+
30
+
31
+ def test_protocol_identity_values_are_checked_without_masking_identity_names() -> None:
32
+ assert has_safe_event_identity({"correlation": {"session_id": "session-123"}})
33
+ assert not has_safe_event_identity({"correlation": {"trace_id": "dbundle_proj_SYNTHETIC_SECRET"}})
34
+ assert not has_safe_event_identity({"sdk_version": "password=SYNTHETIC_SECRET"})
@@ -72,6 +72,17 @@ def test_accepts_valid_batch() -> None:
72
72
  assert response.body["rejected"] == 0
73
73
 
74
74
 
75
+ def test_relay_scrubs_old_browser_payload_before_acceptance() -> None:
76
+ accepted: list[BrowserRelayAcceptedBatch] = []
77
+ handler = BrowserRelayHandler(allowed_origins=["https://example.com"], on_accept=accepted.append)
78
+ event = _valid_event()
79
+ event["payload"]["message"] = "Authorization: Bearer RELAY_SECRET"
80
+ event["payload"]["data"] = {"password": "RELAY_SECRET"}
81
+ response = handler.handle(_make_request({"batch": [event]}))
82
+ assert response.status == 202
83
+ assert "RELAY_SECRET" not in json.dumps(accepted[0].events)
84
+
85
+
75
86
  def test_accepts_analytics_events_and_preserves_analytics_correlation() -> None:
76
87
  fixture = _relay_compliance_fixture("valid-analytics-event")
77
88
  accepted: list[BrowserRelayAcceptedBatch] = []
@@ -70,7 +70,7 @@ def test_standalone_ci_workflow_covers_python_sdk_checks() -> None:
70
70
  workflow = (REPO_ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8")
71
71
 
72
72
  assert "actions/setup-python@v6" in workflow
73
- assert 'python-version: ["3.10", "3.11", "3.12"]' in workflow
73
+ assert 'python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]' in workflow
74
74
  assert "python-version: ${{ matrix.python-version }}" in workflow
75
75
  assert "python -m pip install -e .[dev]" in workflow
76
76
  assert "ruff check src tests" in workflow
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  import platform
4
5
  import sys
5
6
  import uuid
@@ -93,6 +94,28 @@ def test_before_send_mutates_after_redaction_and_before_queueing() -> None:
93
94
  assert events[0]["payload"]["message"] == "mutated"
94
95
 
95
96
 
97
+ def test_hook_cannot_reintroduce_credentials_and_context_is_scrubbed_before_buffering() -> None:
98
+ transport = FakeTransport()
99
+ observed: list[str] = []
100
+
101
+ def hook(event: dict[str, object]) -> dict[str, object]:
102
+ observed.append(json.dumps(event))
103
+ payload = event["payload"]
104
+ assert isinstance(payload, dict)
105
+ payload["message"] = "Bearer POST_HOOK_SECRET"
106
+ return event
107
+
108
+ sdk = DebugBundleSdk(transport=transport)
109
+ sdk.init(project_token="dbundle_proj_test", before_send=hook, redact_fields=[])
110
+ sdk.set_context("password", "PREBUFFER_SECRET")
111
+ assert "PREBUFFER_SECRET" not in json.dumps(sdk._context)
112
+ sdk.capture_message("Authorization: Bearer ORIGINAL_SECRET", level="error")
113
+ sdk.flush()
114
+ assert "ORIGINAL_SECRET" not in observed[0]
115
+ assert "POST_HOOK_SECRET" not in json.dumps(transport.calls)
116
+ assert "PREBUFFER_SECRET" not in json.dumps(transport.calls)
117
+
118
+
96
119
  def test_before_send_drop_invalid_failure_and_sampling_are_safe() -> None:
97
120
  transport = FakeTransport()
98
121
  diagnostics: list[dict[str, object]] = []
@@ -1,29 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from collections.abc import Mapping, Sequence
4
- from typing import Any
5
-
6
- DEFAULT_REDACT_FIELDS = {
7
- "authorization",
8
- "cookie",
9
- "credit_card",
10
- "password",
11
- "secret",
12
- "ssn",
13
- "token",
14
- }
15
-
16
- REDACTED_VALUE = "[REDACTED]"
17
-
18
-
19
- def redact_value(value: Any, redact_fields: set[str]) -> Any:
20
- if isinstance(value, Mapping):
21
- return {
22
- key: REDACTED_VALUE if key.lower() in redact_fields else redact_value(nested_value, redact_fields)
23
- for key, nested_value in value.items()
24
- }
25
-
26
- if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
27
- return [redact_value(item, redact_fields) for item in value]
28
-
29
- return value