trailscope 0.1.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.
- trailscope/__init__.py +55 -0
- trailscope/_capture.py +108 -0
- trailscope/_context.py +125 -0
- trailscope/_ids.py +56 -0
- trailscope/_redact.py +168 -0
- trailscope/_version.py +1 -0
- trailscope/asgi.py +194 -0
- trailscope/celery.py +148 -0
- trailscope/client.py +838 -0
- trailscope/django.py +204 -0
- trailscope/handler.py +49 -0
- trailscope/py.typed +0 -0
- trailscope-0.1.0.dist-info/METADATA +81 -0
- trailscope-0.1.0.dist-info/RECORD +15 -0
- trailscope-0.1.0.dist-info/WHEEL +4 -0
trailscope/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Trailscope SDK for Python: non-blocking request logging for Django, ASGI apps and Celery."""
|
|
2
|
+
|
|
3
|
+
from ._context import (
|
|
4
|
+
TrailscopeContext,
|
|
5
|
+
current_context,
|
|
6
|
+
deserialize_context,
|
|
7
|
+
record_error,
|
|
8
|
+
serialize_context,
|
|
9
|
+
set_attribute,
|
|
10
|
+
set_correlation,
|
|
11
|
+
set_user,
|
|
12
|
+
use_context,
|
|
13
|
+
)
|
|
14
|
+
from ._ids import format_traceparent, new_request_id, new_span_id, new_trace_id, parse_traceparent
|
|
15
|
+
from ._redact import REDACTED, Redactor, mask_ip
|
|
16
|
+
from ._version import __version__
|
|
17
|
+
from .client import (
|
|
18
|
+
DEFAULT_IGNORE_ROUTES,
|
|
19
|
+
Client,
|
|
20
|
+
ClientStats,
|
|
21
|
+
HttpExchange,
|
|
22
|
+
JobScope,
|
|
23
|
+
get_client,
|
|
24
|
+
init,
|
|
25
|
+
)
|
|
26
|
+
from .handler import TrailscopeHandler
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"DEFAULT_IGNORE_ROUTES",
|
|
30
|
+
"REDACTED",
|
|
31
|
+
"Client",
|
|
32
|
+
"ClientStats",
|
|
33
|
+
"HttpExchange",
|
|
34
|
+
"JobScope",
|
|
35
|
+
"Redactor",
|
|
36
|
+
"TrailscopeContext",
|
|
37
|
+
"TrailscopeHandler",
|
|
38
|
+
"__version__",
|
|
39
|
+
"current_context",
|
|
40
|
+
"deserialize_context",
|
|
41
|
+
"format_traceparent",
|
|
42
|
+
"get_client",
|
|
43
|
+
"init",
|
|
44
|
+
"mask_ip",
|
|
45
|
+
"new_request_id",
|
|
46
|
+
"new_span_id",
|
|
47
|
+
"new_trace_id",
|
|
48
|
+
"parse_traceparent",
|
|
49
|
+
"record_error",
|
|
50
|
+
"serialize_context",
|
|
51
|
+
"set_attribute",
|
|
52
|
+
"set_correlation",
|
|
53
|
+
"set_user",
|
|
54
|
+
"use_context",
|
|
55
|
+
]
|
trailscope/_capture.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Body capture policy, serialization and truncation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterable, Mapping
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
BodyCapture = Literal["errors", "always", "never"]
|
|
10
|
+
|
|
11
|
+
_BINARY_TYPES = (
|
|
12
|
+
"multipart/",
|
|
13
|
+
"application/octet-stream",
|
|
14
|
+
"image/",
|
|
15
|
+
"audio/",
|
|
16
|
+
"video/",
|
|
17
|
+
"font/",
|
|
18
|
+
"application/pdf",
|
|
19
|
+
"application/zip",
|
|
20
|
+
"application/gzip",
|
|
21
|
+
"application/x-protobuf",
|
|
22
|
+
"application/grpc",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_binary_content_type(content_type: str | None) -> bool:
|
|
27
|
+
"""Whether a body with this content type should never be captured."""
|
|
28
|
+
if not content_type:
|
|
29
|
+
return False
|
|
30
|
+
return content_type.lower().startswith(_BINARY_TYPES)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def should_capture_bodies(
|
|
34
|
+
policy: BodyCapture, status: int, duration_ms: float, slow_ms: float
|
|
35
|
+
) -> bool:
|
|
36
|
+
if policy == "never":
|
|
37
|
+
return False
|
|
38
|
+
if policy == "always":
|
|
39
|
+
return True
|
|
40
|
+
return status >= 400 or duration_ms >= slow_ms
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def truncate(text: str, limit: int) -> tuple[str, bool]:
|
|
44
|
+
data = text.encode("utf-8")
|
|
45
|
+
if len(data) <= limit:
|
|
46
|
+
return text, False
|
|
47
|
+
# Cut at a byte boundary and drop a trailing partial character.
|
|
48
|
+
return data[:limit].decode("utf-8", errors="ignore"), True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def serialize_body(value: Any, content_type: str | None, limit: int) -> tuple[str, bool] | None:
|
|
52
|
+
"""Serializes a body-like value and truncates it to ``limit`` bytes.
|
|
53
|
+
|
|
54
|
+
Returns None for binary or empty bodies. Raw bodies of binary and multipart
|
|
55
|
+
requests are skipped; already parsed form fields (a dict) are kept.
|
|
56
|
+
"""
|
|
57
|
+
if value is None:
|
|
58
|
+
return None
|
|
59
|
+
if isinstance(value, (bytes, bytearray, memoryview, str)) and is_binary_content_type(
|
|
60
|
+
content_type
|
|
61
|
+
):
|
|
62
|
+
return None
|
|
63
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
64
|
+
raw = bytes(value[: limit + 4])
|
|
65
|
+
if b"\x00" in raw[:512]:
|
|
66
|
+
return None
|
|
67
|
+
text = raw.decode("utf-8", errors="replace")
|
|
68
|
+
if len(value) > limit:
|
|
69
|
+
return truncate(text, limit)[0], True
|
|
70
|
+
elif isinstance(value, str):
|
|
71
|
+
text = value
|
|
72
|
+
else:
|
|
73
|
+
try:
|
|
74
|
+
text = json.dumps(value, ensure_ascii=False, default=str, separators=(",", ":"))
|
|
75
|
+
except (TypeError, ValueError):
|
|
76
|
+
return None
|
|
77
|
+
if not text:
|
|
78
|
+
return None
|
|
79
|
+
return truncate(text, limit)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def split_url(url: str) -> tuple[str, str]:
|
|
83
|
+
"""``/a/b?x=1`` -> (``/a/b``, ``x=1``)."""
|
|
84
|
+
path, _, query = url.partition("?")
|
|
85
|
+
return path or "/", query
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def flatten_headers(
|
|
89
|
+
headers: Mapping[str, Any] | Iterable[tuple[str, Any]] | None,
|
|
90
|
+
) -> dict[str, str]:
|
|
91
|
+
"""Lower-cased names; repeated headers joined with ``, ``."""
|
|
92
|
+
out: dict[str, str] = {}
|
|
93
|
+
if headers is None:
|
|
94
|
+
return out
|
|
95
|
+
items = headers.items() if isinstance(headers, Mapping) else headers
|
|
96
|
+
for k, v in items:
|
|
97
|
+
if v is None:
|
|
98
|
+
continue
|
|
99
|
+
name = (k.decode("latin-1") if isinstance(k, bytes) else str(k)).lower()
|
|
100
|
+
val = (
|
|
101
|
+
v.decode("latin-1")
|
|
102
|
+
if isinstance(v, bytes)
|
|
103
|
+
else ", ".join(v)
|
|
104
|
+
if isinstance(v, list)
|
|
105
|
+
else str(v)
|
|
106
|
+
)
|
|
107
|
+
out[name] = f"{out[name]}, {val}" if name in out else val
|
|
108
|
+
return out
|
trailscope/_context.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Request-scoped context carried through threads and async tasks with contextvars."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from contextvars import ContextVar, Token
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
Scalar = str | int | float | bool
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class TrailscopeContext:
|
|
16
|
+
request_id: str
|
|
17
|
+
trace_id: str
|
|
18
|
+
span_id: str
|
|
19
|
+
parent_span_id: str | None = None
|
|
20
|
+
user_id: str | None = None
|
|
21
|
+
#: Journey keys, e.g. ``{"account_id": "acc_1"}``.
|
|
22
|
+
correlation: dict[str, str] = field(default_factory=dict)
|
|
23
|
+
attributes: dict[str, Scalar] = field(default_factory=dict)
|
|
24
|
+
#: Route pattern, once the framework has matched one.
|
|
25
|
+
route: str | None = None
|
|
26
|
+
#: The exception that failed the request, recorded by an error hook.
|
|
27
|
+
error: BaseException | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_current: ContextVar[TrailscopeContext | None] = ContextVar("trailscope_context", default=None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def current_context() -> TrailscopeContext | None:
|
|
34
|
+
return _current.get()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def set_context(ctx: TrailscopeContext | None) -> Token[TrailscopeContext | None]:
|
|
38
|
+
return _current.set(ctx)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def reset_context(token: Token[TrailscopeContext | None]) -> None:
|
|
42
|
+
try:
|
|
43
|
+
_current.reset(token)
|
|
44
|
+
except ValueError:
|
|
45
|
+
# Reset from another context (e.g. a generator finalized elsewhere).
|
|
46
|
+
_current.set(None)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@contextmanager
|
|
50
|
+
def use_context(ctx: TrailscopeContext) -> Iterator[TrailscopeContext]:
|
|
51
|
+
"""Runs a block inside ``ctx``."""
|
|
52
|
+
token = _current.set(ctx)
|
|
53
|
+
try:
|
|
54
|
+
yield ctx
|
|
55
|
+
finally:
|
|
56
|
+
reset_context(token)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def set_user(user_id: str | int | None) -> None:
|
|
60
|
+
"""Attaches a user ID to the current request."""
|
|
61
|
+
ctx = _current.get()
|
|
62
|
+
if ctx is not None and user_id is not None and user_id != "":
|
|
63
|
+
ctx.user_id = str(user_id)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def set_correlation(key: str, value: str | int) -> None:
|
|
67
|
+
"""Adds a correlation (journey) key to the current request and everything it triggers."""
|
|
68
|
+
ctx = _current.get()
|
|
69
|
+
if ctx is not None and key:
|
|
70
|
+
ctx.correlation[key] = str(value)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def set_attribute(key: str, value: Scalar) -> None:
|
|
74
|
+
"""Adds a free-form attribute to the current request."""
|
|
75
|
+
ctx = _current.get()
|
|
76
|
+
if ctx is not None and key:
|
|
77
|
+
ctx.attributes[key] = value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def record_error(error: BaseException, ctx: TrailscopeContext | None = None) -> None:
|
|
81
|
+
"""Attaches the exception that failed the request (kept when the response is a 5xx)."""
|
|
82
|
+
ctx = ctx or _current.get()
|
|
83
|
+
if ctx is not None and ctx.error is None:
|
|
84
|
+
ctx.error = error
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def serialize_context(ctx: TrailscopeContext | None = None) -> dict[str, Any] | None:
|
|
88
|
+
"""The part of a context that travels with a background job."""
|
|
89
|
+
ctx = ctx or _current.get()
|
|
90
|
+
if ctx is None:
|
|
91
|
+
return None
|
|
92
|
+
out: dict[str, Any] = {
|
|
93
|
+
"requestId": ctx.request_id,
|
|
94
|
+
"traceId": ctx.trace_id,
|
|
95
|
+
"spanId": ctx.span_id,
|
|
96
|
+
}
|
|
97
|
+
if ctx.user_id:
|
|
98
|
+
out["userId"] = ctx.user_id
|
|
99
|
+
if ctx.correlation:
|
|
100
|
+
out["correlation"] = dict(ctx.correlation)
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def deserialize_context(value: object) -> dict[str, Any] | None:
|
|
105
|
+
"""Validates a serialized context (from job data or message headers)."""
|
|
106
|
+
if not isinstance(value, dict):
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
def s(key: str) -> str | None:
|
|
110
|
+
v = value.get(key)
|
|
111
|
+
return v if isinstance(v, str) and len(v) <= 128 else None
|
|
112
|
+
|
|
113
|
+
correlation: dict[str, str] = {}
|
|
114
|
+
raw = value.get("correlation")
|
|
115
|
+
if isinstance(raw, dict):
|
|
116
|
+
for k, v in raw.items():
|
|
117
|
+
if isinstance(k, str) and isinstance(v, (str, int)):
|
|
118
|
+
correlation[k] = str(v)
|
|
119
|
+
return {
|
|
120
|
+
"request_id": s("requestId"),
|
|
121
|
+
"trace_id": s("traceId"),
|
|
122
|
+
"span_id": s("spanId"),
|
|
123
|
+
"user_id": s("userId"),
|
|
124
|
+
"correlation": correlation,
|
|
125
|
+
}
|
trailscope/_ids.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Request, trace and span IDs, and W3C `traceparent`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
_TRACEPARENT = re.compile(r"^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$")
|
|
11
|
+
_REQUEST_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def new_request_id() -> str:
|
|
15
|
+
"""32 hex characters, like the server's generated IDs."""
|
|
16
|
+
return uuid.uuid4().hex
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def new_trace_id() -> str:
|
|
20
|
+
return secrets.token_hex(16)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def new_span_id() -> str:
|
|
24
|
+
return secrets.token_hex(8)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class TraceParent:
|
|
29
|
+
trace_id: str
|
|
30
|
+
parent_span_id: str
|
|
31
|
+
sampled: bool
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_traceparent(header: str | None) -> TraceParent | None:
|
|
35
|
+
"""Parses a W3C `traceparent` header; None when it is missing or invalid."""
|
|
36
|
+
if not header:
|
|
37
|
+
return None
|
|
38
|
+
m = _TRACEPARENT.match(header.strip().lower())
|
|
39
|
+
if not m:
|
|
40
|
+
return None
|
|
41
|
+
version, trace_id, span_id, flags = m.groups()
|
|
42
|
+
if version == "ff" or set(trace_id) == {"0"} or set(span_id) == {"0"}:
|
|
43
|
+
return None
|
|
44
|
+
return TraceParent(trace_id, span_id, (int(flags, 16) & 1) == 1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def format_traceparent(trace_id: str, span_id: str, sampled: bool = True) -> str:
|
|
48
|
+
return f"00-{trace_id}-{span_id}-{'01' if sampled else '00'}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def accept_request_id(value: str | None) -> str | None:
|
|
52
|
+
"""A client-provided request ID, only if it looks sane."""
|
|
53
|
+
if not value:
|
|
54
|
+
return None
|
|
55
|
+
v = value.strip()
|
|
56
|
+
return v if _REQUEST_ID.match(v) else None
|
trailscope/_redact.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Redaction of headers, bodies, query strings and IPs. Same rules as the Node SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Iterable, Mapping
|
|
8
|
+
from re import Pattern
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import unquote_plus
|
|
11
|
+
|
|
12
|
+
REDACTED = "[REDACTED]"
|
|
13
|
+
|
|
14
|
+
DEFAULT_REDACT_HEADERS = ("authorization", "cookie", "set-cookie", "x-api-key")
|
|
15
|
+
|
|
16
|
+
DEFAULT_REDACT_KEYS = (
|
|
17
|
+
"password",
|
|
18
|
+
"passwd",
|
|
19
|
+
"secret",
|
|
20
|
+
"token",
|
|
21
|
+
"access_token",
|
|
22
|
+
"refresh_token",
|
|
23
|
+
"otp",
|
|
24
|
+
"pin",
|
|
25
|
+
"cvv",
|
|
26
|
+
"card",
|
|
27
|
+
"card_number",
|
|
28
|
+
"iban",
|
|
29
|
+
"ssn",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_IPV4 = re.compile(r"^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.\d{1,3}$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _norm_key(k: str) -> str:
|
|
36
|
+
"""``accessToken``, ``access-token`` and ``ACCESS_TOKEN`` all become ``accesstoken``."""
|
|
37
|
+
return k.replace("_", "").replace("-", "").lower()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Redactor:
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
headers: Iterable[str] = (),
|
|
45
|
+
keys: Iterable[str] = (),
|
|
46
|
+
patterns: Iterable[str | Pattern[str]] = (),
|
|
47
|
+
mask_ip: bool = True,
|
|
48
|
+
disable_defaults: bool = False,
|
|
49
|
+
) -> None:
|
|
50
|
+
base_headers = () if disable_defaults else DEFAULT_REDACT_HEADERS
|
|
51
|
+
base_keys: list[str] = [] if disable_defaults else list(DEFAULT_REDACT_KEYS)
|
|
52
|
+
self._headers = {h.lower() for h in (*base_headers, *headers)}
|
|
53
|
+
self._paths: list[list[str]] = []
|
|
54
|
+
simple = list(base_keys)
|
|
55
|
+
for k in keys:
|
|
56
|
+
if "." in k:
|
|
57
|
+
self._paths.append([_norm_key(p) for p in k.split(".")])
|
|
58
|
+
elif k:
|
|
59
|
+
simple.append(k)
|
|
60
|
+
self._keys = {_norm_key(k) for k in simple}
|
|
61
|
+
self._patterns = [re.compile(p) if isinstance(p, str) else p for p in patterns]
|
|
62
|
+
self.mask_ip = mask_ip
|
|
63
|
+
self._raw_keys: Pattern[str] | None = None
|
|
64
|
+
if simple:
|
|
65
|
+
alts = "|".join("[_-]?".join(re.escape(part) for part in k.split("_")) for k in simple)
|
|
66
|
+
self._raw_keys = re.compile(
|
|
67
|
+
r'("(?:' + alts + r')"\s*:\s*)("(?:[^"\\]|\\.)*"?|[^,}\]\s]+)', re.IGNORECASE
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def redact_headers(self, headers: Mapping[str, str]) -> dict[str, str]:
|
|
71
|
+
out: dict[str, str] = {}
|
|
72
|
+
for name, value in headers.items():
|
|
73
|
+
lower = name.lower()
|
|
74
|
+
out[lower] = REDACTED if lower in self._headers else self.apply_patterns(value)
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
def redact_body(self, body: str, content_type: str = "") -> str:
|
|
78
|
+
if not body:
|
|
79
|
+
return body
|
|
80
|
+
t = body.lstrip()
|
|
81
|
+
if t.startswith(("{", "[")):
|
|
82
|
+
try:
|
|
83
|
+
parsed = json.loads(body)
|
|
84
|
+
except ValueError:
|
|
85
|
+
return self._redact_raw_text(body)
|
|
86
|
+
return json.dumps(
|
|
87
|
+
self.redact_value(parsed, []), ensure_ascii=False, separators=(",", ":")
|
|
88
|
+
)
|
|
89
|
+
if "x-www-form-urlencoded" in content_type:
|
|
90
|
+
return self.redact_urlencoded(body)
|
|
91
|
+
return self._redact_raw_text(body)
|
|
92
|
+
|
|
93
|
+
def redact_urlencoded(self, s: str) -> str:
|
|
94
|
+
parts = []
|
|
95
|
+
for pair in s.split("&"):
|
|
96
|
+
k, sep, v = pair.partition("=")
|
|
97
|
+
if not sep:
|
|
98
|
+
parts.append(pair)
|
|
99
|
+
continue
|
|
100
|
+
try:
|
|
101
|
+
decoded = unquote_plus(k)
|
|
102
|
+
except Exception:
|
|
103
|
+
decoded = k
|
|
104
|
+
if _norm_key(decoded) in self._keys:
|
|
105
|
+
parts.append(f"{k}={REDACTED}")
|
|
106
|
+
else:
|
|
107
|
+
parts.append(f"{k}={self.apply_patterns(v)}")
|
|
108
|
+
return "&".join(parts)
|
|
109
|
+
|
|
110
|
+
def redact_query(self, q: str) -> str:
|
|
111
|
+
return self.redact_urlencoded(q.lstrip("?")) if q else q
|
|
112
|
+
|
|
113
|
+
def redact_value(self, v: Any, path: list[str]) -> Any:
|
|
114
|
+
"""Redacts a structured value: JSON bodies and job payloads."""
|
|
115
|
+
if isinstance(v, list):
|
|
116
|
+
return [self.redact_value(item, [*path, "*"]) for item in v]
|
|
117
|
+
if isinstance(v, dict):
|
|
118
|
+
out: dict[str, Any] = {}
|
|
119
|
+
for k, child in v.items():
|
|
120
|
+
nk = _norm_key(str(k))
|
|
121
|
+
p = [*path, nk]
|
|
122
|
+
if (nk in self._keys or self._path_matches(p)) and child is not None:
|
|
123
|
+
out[k] = REDACTED
|
|
124
|
+
else:
|
|
125
|
+
out[k] = self.redact_value(child, p)
|
|
126
|
+
return out
|
|
127
|
+
if isinstance(v, str):
|
|
128
|
+
return self.apply_patterns(v)
|
|
129
|
+
return v
|
|
130
|
+
|
|
131
|
+
def apply_patterns(self, s: str) -> str:
|
|
132
|
+
for p in self._patterns:
|
|
133
|
+
s = p.sub(REDACTED, s)
|
|
134
|
+
return s
|
|
135
|
+
|
|
136
|
+
def ip(self, ip: str | None) -> str | None:
|
|
137
|
+
if not ip or not self.mask_ip:
|
|
138
|
+
return ip
|
|
139
|
+
return mask_ip(ip)
|
|
140
|
+
|
|
141
|
+
def _redact_raw_text(self, s: str) -> str:
|
|
142
|
+
if self._raw_keys is not None:
|
|
143
|
+
s = self._raw_keys.sub(lambda m: f'{m.group(1)}"{REDACTED}"', s)
|
|
144
|
+
return self.apply_patterns(s)
|
|
145
|
+
|
|
146
|
+
def _path_matches(self, path: list[str]) -> bool:
|
|
147
|
+
return any(
|
|
148
|
+
len(p) == len(path)
|
|
149
|
+
and all(
|
|
150
|
+
want == "*" or got == "*" or want == got for want, got in zip(p, path, strict=True)
|
|
151
|
+
)
|
|
152
|
+
for p in self._paths
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def mask_ip(raw: str) -> str:
|
|
157
|
+
"""Zeroes the last IPv4 octet, or keeps the first 48 bits of an IPv6 address."""
|
|
158
|
+
ip = raw.strip()
|
|
159
|
+
if ip.startswith("::ffff:") and "." in ip:
|
|
160
|
+
ip = ip[7:]
|
|
161
|
+
m = _IPV4.match(ip)
|
|
162
|
+
if m:
|
|
163
|
+
return f"{m.group(1)}.{m.group(2)}.{m.group(3)}.0"
|
|
164
|
+
if ":" in ip:
|
|
165
|
+
head = ip.split("::")[0]
|
|
166
|
+
parts = [p for p in head.split(":") if p]
|
|
167
|
+
return ":".join(parts[:3]) + "::"
|
|
168
|
+
return ""
|
trailscope/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|