debugbundle-python 1.4.2__py3-none-any.whl → 2.0.0__py3-none-any.whl
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.
- debugbundle/capture_hooks.py +65 -0
- debugbundle/core.py +379 -251
- debugbundle/delivery_hooks.py +62 -0
- debugbundle/event_support.py +1 -1
- debugbundle/exception_snapshot.py +74 -0
- debugbundle/flush_aggregates.py +37 -0
- debugbundle/probe_capture.py +101 -0
- debugbundle/process_support.py +59 -0
- debugbundle/queue_admission.py +39 -0
- debugbundle/redaction.py +197 -0
- debugbundle/relay.py +8 -0
- debugbundle/request_capture_policy.py +45 -0
- debugbundle/suppression.py +13 -6
- {debugbundle_python-1.4.2.dist-info → debugbundle_python-2.0.0.dist-info}/METADATA +12 -5
- {debugbundle_python-1.4.2.dist-info → debugbundle_python-2.0.0.dist-info}/RECORD +18 -10
- {debugbundle_python-1.4.2.dist-info → debugbundle_python-2.0.0.dist-info}/WHEEL +0 -0
- {debugbundle_python-1.4.2.dist-info → debugbundle_python-2.0.0.dist-info}/licenses/LICENSE +0 -0
- {debugbundle_python-1.4.2.dist-info → debugbundle_python-2.0.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Finalize one reserved event at a time without retaining uncharged hook output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, cast
|
|
6
|
+
|
|
7
|
+
from .request_capture_policy import should_capture_request_event
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .core import DebugBundleSdk
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def replacement_allowed(sdk: DebugBundleSdk, event: dict[str, object]) -> bool:
|
|
14
|
+
payload = cast(dict[str, object], event["payload"])
|
|
15
|
+
if event["event_type"] == "log_event":
|
|
16
|
+
return sdk._log_level_eligible(cast(str, payload["level"]))
|
|
17
|
+
if event["event_type"] == "request_event":
|
|
18
|
+
return should_capture_request_event(sdk._capture_policy, payload, payload)
|
|
19
|
+
if event["event_type"] == "probe_event":
|
|
20
|
+
return sdk._capture_policy.capture_probe_events == "standalone_when_activated"
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def finalize_batch(
|
|
25
|
+
sdk: DebugBundleSdk,
|
|
26
|
+
batch: list[dict[str, object]],
|
|
27
|
+
generation: int,
|
|
28
|
+
max_bytes: int,
|
|
29
|
+
) -> list[dict[str, object]] | None:
|
|
30
|
+
retained_index = 0
|
|
31
|
+
for snapshot_index, original in enumerate(batch):
|
|
32
|
+
try:
|
|
33
|
+
replacement = sdk._apply_before_send_event(original)
|
|
34
|
+
replacement_bytes = sdk._event_bytes(replacement) if replacement is not None else 0
|
|
35
|
+
except BaseException:
|
|
36
|
+
# Hook exceptions/invalid returns already use the protected original
|
|
37
|
+
# inside apply_before_send. Other failures must withhold telemetry.
|
|
38
|
+
replacement, replacement_bytes = None, 0
|
|
39
|
+
with sdk._lock:
|
|
40
|
+
if generation != sdk._generation:
|
|
41
|
+
return None
|
|
42
|
+
original_bytes = sdk._buffer_sizes[id(original)]
|
|
43
|
+
allowed = replacement is not None and replacement_allowed(sdk, replacement)
|
|
44
|
+
fits = sdk._buffer_bytes - original_bytes + replacement_bytes <= max_bytes
|
|
45
|
+
if allowed and fits:
|
|
46
|
+
assert replacement is not None
|
|
47
|
+
sdk._buffer[retained_index] = replacement
|
|
48
|
+
sdk._buffer_sizes.pop(id(original))
|
|
49
|
+
sdk._buffer_sizes[id(replacement)] = replacement_bytes
|
|
50
|
+
sdk._buffer_bytes += replacement_bytes - original_bytes
|
|
51
|
+
retained_index += 1
|
|
52
|
+
batch[snapshot_index] = replacement
|
|
53
|
+
else:
|
|
54
|
+
if allowed and not fits:
|
|
55
|
+
sdk._pressure_drops += 1
|
|
56
|
+
sdk._buffer.pop(retained_index)
|
|
57
|
+
sdk._buffer_sizes.pop(id(original))
|
|
58
|
+
sdk._buffer_bytes -= original_bytes
|
|
59
|
+
sdk._inflight_count -= 1
|
|
60
|
+
# Release the snapshot reference before another callback can wait.
|
|
61
|
+
batch[snapshot_index] = {}
|
|
62
|
+
return [event for event in batch if event]
|
debugbundle/event_support.py
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Bounded exception details without invoking application-defined string renderers or source reads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
MAX_CAUSES = 8
|
|
6
|
+
MAX_FRAMES = 64
|
|
7
|
+
MAX_STACK_CHARS = 16 * 1024
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def exception_details(error: BaseException) -> tuple[str, str, str]:
|
|
11
|
+
name = _exception_name(error)
|
|
12
|
+
message = _safe_message(error)
|
|
13
|
+
lines: list[str] = []
|
|
14
|
+
visited: set[int] = set()
|
|
15
|
+
current: BaseException | None = error
|
|
16
|
+
frames_seen = 0
|
|
17
|
+
|
|
18
|
+
while current is not None and len(visited) < MAX_CAUSES and id(current) not in visited:
|
|
19
|
+
visited.add(id(current))
|
|
20
|
+
current_name = _exception_name(current)
|
|
21
|
+
lines.append(f"{current_name}: {_safe_message(current)}")
|
|
22
|
+
try:
|
|
23
|
+
tb = BaseException.__dict__["__traceback__"].__get__(current)
|
|
24
|
+
except BaseException:
|
|
25
|
+
tb = None
|
|
26
|
+
while tb is not None and frames_seen < MAX_FRAMES:
|
|
27
|
+
try:
|
|
28
|
+
code = tb.tb_frame.f_code
|
|
29
|
+
filename = code.co_filename[:256]
|
|
30
|
+
function = code.co_name[:128]
|
|
31
|
+
line = tb.tb_lineno
|
|
32
|
+
tb = tb.tb_next
|
|
33
|
+
except BaseException:
|
|
34
|
+
break
|
|
35
|
+
lines.append(f" File {filename}, line {line}, in {function}")
|
|
36
|
+
frames_seen += 1
|
|
37
|
+
if frames_seen >= MAX_FRAMES:
|
|
38
|
+
lines.append(" ... additional frames omitted")
|
|
39
|
+
break
|
|
40
|
+
try:
|
|
41
|
+
next_error = BaseException.__dict__["__cause__"].__get__(current)
|
|
42
|
+
if next_error is None and not BaseException.__dict__["__suppress_context__"].__get__(current):
|
|
43
|
+
next_error = BaseException.__dict__["__context__"].__get__(current)
|
|
44
|
+
except BaseException:
|
|
45
|
+
next_error = None
|
|
46
|
+
current = next_error
|
|
47
|
+
if current is not None:
|
|
48
|
+
lines.append("Caused by:")
|
|
49
|
+
|
|
50
|
+
return name, message, "\n".join(lines)[:MAX_STACK_CHARS]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _exception_name(error: BaseException) -> str:
|
|
54
|
+
# Invoke built-in descriptors directly: subclass properties, __getattribute__,
|
|
55
|
+
# metaclass accessors, and truthiness must never execute on capture callers.
|
|
56
|
+
return str(type.__dict__["__name__"].__get__(type(error)))[:256]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _safe_message(error: BaseException) -> str:
|
|
60
|
+
try:
|
|
61
|
+
args = BaseException.__dict__["args"].__get__(error)
|
|
62
|
+
if not isinstance(args, tuple):
|
|
63
|
+
return "[message unavailable]"
|
|
64
|
+
parts: list[str] = []
|
|
65
|
+
for value in args[:4]:
|
|
66
|
+
if issubclass(type(value), str):
|
|
67
|
+
parts.append(str.__getitem__(value, slice(0, 1024)))
|
|
68
|
+
elif type(value) in (int, float, bool):
|
|
69
|
+
parts.append(str(value))
|
|
70
|
+
else:
|
|
71
|
+
parts.append("[unsupported argument]")
|
|
72
|
+
return ", ".join(parts)[:4096]
|
|
73
|
+
except BaseException:
|
|
74
|
+
return "[message unavailable]"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Prepare bounded SDK summaries away from the capture admission lock."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, cast
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .core import DebugBundleSdk
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def prepare_flush_aggregates(
|
|
12
|
+
sdk: DebugBundleSdk,
|
|
13
|
+
suppression: list[dict[str, object]],
|
|
14
|
+
pressure_count: int,
|
|
15
|
+
) -> tuple[list[tuple[dict[str, object], int]], tuple[dict[str, object], int] | None]:
|
|
16
|
+
prepared: list[tuple[dict[str, object], int]] = []
|
|
17
|
+
for aggregate in suppression:
|
|
18
|
+
event_type = aggregate.get("event_type")
|
|
19
|
+
payload = aggregate.get("payload")
|
|
20
|
+
if not isinstance(event_type, str) or not isinstance(payload, dict):
|
|
21
|
+
continue
|
|
22
|
+
aggregate.update(sdk._base_event(event_type, cast(dict[str, object], payload)))
|
|
23
|
+
protected = sdk._protect_event(aggregate)
|
|
24
|
+
if protected is not None:
|
|
25
|
+
prepared.append((protected, sdk._event_bytes(protected)))
|
|
26
|
+
|
|
27
|
+
pressure: tuple[dict[str, object], int] | None = None
|
|
28
|
+
if pressure_count:
|
|
29
|
+
event = sdk._base_event("log_event", {
|
|
30
|
+
"message": f"{pressure_count} SDK events suppressed due to queue pressure",
|
|
31
|
+
"level": "warning",
|
|
32
|
+
"attributes": {"suppressed_count": pressure_count, "reason": "queue_pressure"},
|
|
33
|
+
})
|
|
34
|
+
protected = sdk._protect_event(event)
|
|
35
|
+
if protected is not None:
|
|
36
|
+
pressure = (protected, sdk._event_bytes(protected))
|
|
37
|
+
return prepared, pressure
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Probe capture with short admission/commit locks and no application callback under them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING, cast
|
|
9
|
+
|
|
10
|
+
from .capture_hooks import try_capture_lock
|
|
11
|
+
from .event_support import iso_now
|
|
12
|
+
from .process_support import ensure_current_process
|
|
13
|
+
from .redaction import sanitize_telemetry
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .core import DebugBundleSdk
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ProbeEntry:
|
|
21
|
+
label: str
|
|
22
|
+
data: dict[str, object]
|
|
23
|
+
timestamp: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def capture_probe(
|
|
27
|
+
sdk: DebugBundleSdk,
|
|
28
|
+
label: str,
|
|
29
|
+
data: object | Callable[[], object],
|
|
30
|
+
opts: Mapping[str, object] | None,
|
|
31
|
+
) -> None:
|
|
32
|
+
try:
|
|
33
|
+
ensure_current_process(sdk)
|
|
34
|
+
options = dict(opts or {})
|
|
35
|
+
now_ms = int(sdk._time_provider() * 1000)
|
|
36
|
+
if not try_capture_lock(sdk):
|
|
37
|
+
sdk._pressure_drops += 1
|
|
38
|
+
return
|
|
39
|
+
try:
|
|
40
|
+
if not sdk._enabled:
|
|
41
|
+
return
|
|
42
|
+
heavy = options.get("heavy") is True
|
|
43
|
+
directives = sdk._find_matching_probe_directives(label, now_ms)
|
|
44
|
+
if heavy and not directives:
|
|
45
|
+
return
|
|
46
|
+
if label not in sdk._probe_buffers and len(sdk._probe_buffers) >= sdk._max_probe_labels:
|
|
47
|
+
return
|
|
48
|
+
redact_fields = set(sdk._redact_fields)
|
|
49
|
+
generation = sdk._generation
|
|
50
|
+
finally:
|
|
51
|
+
sdk._lock.release()
|
|
52
|
+
|
|
53
|
+
# A host supplier, mapping iterator, privacy walk, clock, or serializer may
|
|
54
|
+
# be slow or hostile. None may hold the capture lock or block exceptions.
|
|
55
|
+
protected_label = sanitize_telemetry(label, redact_fields)
|
|
56
|
+
value = data() if callable(data) else data
|
|
57
|
+
if not isinstance(value, Mapping):
|
|
58
|
+
value = {"value": value}
|
|
59
|
+
protected_value = sanitize_telemetry(dict(value), redact_fields)
|
|
60
|
+
if not isinstance(protected_label, str) or not isinstance(protected_value, dict):
|
|
61
|
+
return
|
|
62
|
+
label = protected_label
|
|
63
|
+
redacted_value = cast(dict[str, object], protected_value)
|
|
64
|
+
entry = None if heavy else ProbeEntry(label, redacted_value, iso_now(sdk._time_provider))
|
|
65
|
+
|
|
66
|
+
prepared: list[tuple[str, dict[str, object], int]] = []
|
|
67
|
+
for directive in directives:
|
|
68
|
+
payload: dict[str, object] = {
|
|
69
|
+
"label": label,
|
|
70
|
+
"activation_id": directive.id,
|
|
71
|
+
"probe_label_pattern": directive.label_pattern,
|
|
72
|
+
"data": dict(redacted_value),
|
|
73
|
+
}
|
|
74
|
+
event = sdk._protect_event(sdk._base_event("probe_event", payload))
|
|
75
|
+
if event is not None:
|
|
76
|
+
prepared.append((directive.id, event, sdk._event_bytes(event)))
|
|
77
|
+
|
|
78
|
+
refreshed_now_ms = int(sdk._time_provider() * 1000)
|
|
79
|
+
if not try_capture_lock(sdk):
|
|
80
|
+
sdk._pressure_drops += 1
|
|
81
|
+
return
|
|
82
|
+
try:
|
|
83
|
+
if not sdk._enabled or generation != sdk._generation:
|
|
84
|
+
return
|
|
85
|
+
if label not in sdk._probe_buffers and len(sdk._probe_buffers) >= sdk._max_probe_labels:
|
|
86
|
+
return
|
|
87
|
+
active_ids = {directive.id for directive in sdk._find_matching_probe_directives(label, refreshed_now_ms)}
|
|
88
|
+
if heavy and not active_ids:
|
|
89
|
+
return
|
|
90
|
+
if entry is not None:
|
|
91
|
+
bucket = sdk._probe_buffers.setdefault(label, deque(maxlen=sdk._max_probe_entries_per_label))
|
|
92
|
+
bucket.append(entry)
|
|
93
|
+
if sdk._capture_policy.capture_probe_events == "standalone_when_activated":
|
|
94
|
+
for directive_id, event, event_bytes in prepared:
|
|
95
|
+
if directive_id in active_ids:
|
|
96
|
+
sdk._offer_protected_event(event, event_bytes)
|
|
97
|
+
finally:
|
|
98
|
+
sdk._lock.release()
|
|
99
|
+
except BaseException:
|
|
100
|
+
# Probe callbacks and SDK failures must remain observational.
|
|
101
|
+
return
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Discard pre-fork SDK ownership without touching inherited thread locks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
from contextvars import ContextVar
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from .suppression import EventSuppressionTracker
|
|
11
|
+
from .transport import HttpTransport
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .core import DebugBundleSdk
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ensure_current_process(sdk: DebugBundleSdk) -> None:
|
|
18
|
+
pid = os.getpid()
|
|
19
|
+
if sdk._pid == pid:
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
# The parent may have forked while another thread owned its lock. Never
|
|
23
|
+
# acquire, cancel, close, or join any inherited thread-owned object here.
|
|
24
|
+
sdk._lock = threading.RLock()
|
|
25
|
+
sdk._timer = None
|
|
26
|
+
sdk._remote_config_timer = None
|
|
27
|
+
sdk._initial_config_ready = threading.Event()
|
|
28
|
+
sdk._initial_config_ready.set()
|
|
29
|
+
sdk._generation += 1
|
|
30
|
+
sdk._sending = False
|
|
31
|
+
sdk._inflight_count = 0
|
|
32
|
+
sdk._timer_due_at = 0.0
|
|
33
|
+
sdk._buffer = []
|
|
34
|
+
sdk._buffer_sizes = {}
|
|
35
|
+
sdk._buffer_bytes = 0
|
|
36
|
+
sdk._pending_low_priority = 0
|
|
37
|
+
sdk._context = {}
|
|
38
|
+
sdk._scoped_context = ContextVar("debugbundle_scoped_context", default=None)
|
|
39
|
+
sdk._request_trigger_directives = ContextVar("debugbundle_request_trigger_directives", default=None)
|
|
40
|
+
sdk._probe_buffers = {}
|
|
41
|
+
sdk._suppression = EventSuppressionTracker()
|
|
42
|
+
sdk._pressure_drops = 0
|
|
43
|
+
sdk._last_pressure_report_at = 0.0
|
|
44
|
+
sdk._retry_after = 0.0
|
|
45
|
+
sdk._last_event_at = None
|
|
46
|
+
sdk._consecutive_failures = 0
|
|
47
|
+
sdk._remote_config_etag = None
|
|
48
|
+
sdk._remote_config_snapshot = None
|
|
49
|
+
|
|
50
|
+
# Retain the parent's effective policy until the child explicitly reinitializes;
|
|
51
|
+
# resetting to a default here could widen capture after a restricted policy.
|
|
52
|
+
if sdk._http_transport is not None:
|
|
53
|
+
sdk._http_transport = None
|
|
54
|
+
try:
|
|
55
|
+
sdk._http_transport = HttpTransport(sdk._endpoint)
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
sdk._transport = sdk._http_transport
|
|
59
|
+
sdk._pid = pid
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Constant-time overload admission and pending-event priority helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .core import DebugBundleSdk
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def high_priority_event(event: dict[str, object]) -> bool:
|
|
12
|
+
if event.get("event_type") == "backend_exception":
|
|
13
|
+
return True
|
|
14
|
+
payload = event.get("payload")
|
|
15
|
+
return isinstance(payload, dict) and (
|
|
16
|
+
payload.get("level") in ("error", "critical")
|
|
17
|
+
or (event.get("event_type") == "request_event" and type(payload.get("response_status")) is int
|
|
18
|
+
and payload["response_status"] >= 500)
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def may_prepare_event(sdk: DebugBundleSdk, high_priority: bool, max_events: int, max_bytes: int) -> bool:
|
|
23
|
+
if len(sdk._buffer) < max_events and sdk._buffer_bytes < max_bytes:
|
|
24
|
+
return True
|
|
25
|
+
if high_priority and sdk._pending_low_priority > 0:
|
|
26
|
+
return True
|
|
27
|
+
sdk._pressure_drops = min(2**63 - 1, sdk._pressure_drops + 1)
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def evict_low_priority(sdk: DebugBundleSdk) -> bool:
|
|
32
|
+
for index in range(sdk._inflight_count, len(sdk._buffer)):
|
|
33
|
+
if not high_priority_event(sdk._buffer[index]):
|
|
34
|
+
evicted = sdk._buffer.pop(index)
|
|
35
|
+
sdk._buffer_bytes -= sdk._buffer_sizes.pop(id(evicted))
|
|
36
|
+
sdk._pending_low_priority -= 1
|
|
37
|
+
sdk._pressure_drops = min(2**63 - 1, sdk._pressure_drops + 1)
|
|
38
|
+
return True
|
|
39
|
+
return False
|
debugbundle/redaction.py
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
3
5
|
from collections.abc import Mapping, Sequence
|
|
4
6
|
from typing import Any
|
|
7
|
+
from urllib.parse import parse_qsl, unquote, urlencode, urlsplit, urlunsplit
|
|
5
8
|
|
|
6
9
|
DEFAULT_REDACT_FIELDS = {
|
|
7
10
|
"authorization",
|
|
@@ -14,6 +17,200 @@ DEFAULT_REDACT_FIELDS = {
|
|
|
14
17
|
}
|
|
15
18
|
|
|
16
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)
|
|
17
214
|
|
|
18
215
|
|
|
19
216
|
def redact_value(value: Any, redact_fields: set[str]) -> Any:
|
debugbundle/relay.py
CHANGED
|
@@ -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
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Metadata-only request admission before request payload construction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
from .config import CapturePolicy
|
|
8
|
+
from .event_support import is_immediate_request_incident_status
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def should_capture_request_event(
|
|
12
|
+
policy: CapturePolicy,
|
|
13
|
+
request: Mapping[str, object] | None,
|
|
14
|
+
response: Mapping[str, object] | None,
|
|
15
|
+
) -> bool:
|
|
16
|
+
status_code = None
|
|
17
|
+
if response is not None:
|
|
18
|
+
candidate = response.get("status_code") or response.get("response_status")
|
|
19
|
+
if isinstance(candidate, int):
|
|
20
|
+
status_code = candidate
|
|
21
|
+
request_path = None
|
|
22
|
+
http_method = None
|
|
23
|
+
if request is not None:
|
|
24
|
+
path_candidate = request.get("path") or request.get("url")
|
|
25
|
+
method_candidate = request.get("method")
|
|
26
|
+
request_path = path_candidate if isinstance(path_candidate, str) else None
|
|
27
|
+
http_method = method_candidate if isinstance(method_candidate, str) else None
|
|
28
|
+
if is_immediate_request_incident_status(
|
|
29
|
+
status_code,
|
|
30
|
+
policy.preset,
|
|
31
|
+
policy.immediate_client_error_statuses,
|
|
32
|
+
request_path,
|
|
33
|
+
http_method,
|
|
34
|
+
policy.immediate_client_error_path_rules,
|
|
35
|
+
):
|
|
36
|
+
return True
|
|
37
|
+
if policy.capture_request_events == "off":
|
|
38
|
+
return False
|
|
39
|
+
if policy.capture_request_events == "all":
|
|
40
|
+
return True
|
|
41
|
+
if response is None or status_code is None:
|
|
42
|
+
return policy.capture_request_events == "filtered"
|
|
43
|
+
if policy.capture_request_events == "failures_only":
|
|
44
|
+
return status_code >= 500
|
|
45
|
+
return False
|