wayscribe 0.2.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.
wayscribe/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """Wayscribe native Python recorder."""
2
+
3
+ from ._version import __version__
4
+ from .propagation import (
5
+ extract_http_context,
6
+ extract_payload,
7
+ extract_sqs_context,
8
+ has_journey,
9
+ inject_http_headers,
10
+ inject_payload,
11
+ inject_sqs_attributes,
12
+ )
13
+ from .recorder import Journey, Recorder, create_recorder
14
+ from .timing import http_metadata, queue_metadata
15
+
16
+ __all__ = [
17
+ "__version__",
18
+ "Journey",
19
+ "Recorder",
20
+ "create_recorder",
21
+ "extract_http_context",
22
+ "extract_payload",
23
+ "extract_sqs_context",
24
+ "has_journey",
25
+ "http_metadata",
26
+ "inject_http_headers",
27
+ "inject_payload",
28
+ "inject_sqs_attributes",
29
+ "queue_metadata",
30
+ ]
wayscribe/_capture.py ADDED
@@ -0,0 +1,473 @@
1
+ """Bounded synchronous capture. Walk only explicit containers, never attributes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ if TYPE_CHECKING:
8
+ from ._config import Config
9
+ import base64
10
+ import datetime as dt
11
+ import json
12
+ import math
13
+ import re
14
+ from collections.abc import Mapping
15
+ from dataclasses import dataclass, field
16
+ from decimal import Decimal
17
+
18
+ from ._diagnostics import (
19
+ MAX_SECRET_NAME_LENGTH,
20
+ MAX_SECRET_NAMES,
21
+ MAX_SECRET_PATH_LENGTH,
22
+ )
23
+
24
+ UNSET = object()
25
+ REDACTED = "[REDACTED]"
26
+ TOO_LARGE = "[PAYLOAD_TOO_LARGE]"
27
+ UNCAPTURABLE = "[UNCAPTURABLE]"
28
+ SECRET_NAMES = [
29
+ "authorization",
30
+ "proxy-authorization",
31
+ "cookie",
32
+ "set-cookie",
33
+ "x-api-key",
34
+ "password",
35
+ "access_token",
36
+ "refresh_token",
37
+ "client_secret",
38
+ "api_key",
39
+ "secret",
40
+ "stripe-signature",
41
+ "x-hub-signature",
42
+ "x-hub-signature-256",
43
+ "x-slack-signature",
44
+ "x-hubspot-signature",
45
+ "x-hubspot-signature-v3",
46
+ "x-twilio-signature",
47
+ "x-shopify-hmac-sha256",
48
+ ]
49
+
50
+
51
+ def fold(name: str) -> str:
52
+ return name.lower().replace("-", "").replace("_", "")
53
+
54
+
55
+ SECRETS = frozenset(map(fold, SECRET_NAMES))
56
+ KNOWN_HEADERS = frozenset(
57
+ [
58
+ ":authority",
59
+ ":method",
60
+ ":path",
61
+ ":protocol",
62
+ ":scheme",
63
+ ":status",
64
+ "accept",
65
+ "accept-encoding",
66
+ "accept-language",
67
+ "authorization",
68
+ "cache-control",
69
+ "connection",
70
+ "content-length",
71
+ "content-type",
72
+ "cookie",
73
+ "date",
74
+ "etag",
75
+ "host",
76
+ "if-none-match",
77
+ "location",
78
+ "origin",
79
+ "proxy-authorization",
80
+ "referer",
81
+ "set-cookie",
82
+ "transfer-encoding",
83
+ "user-agent",
84
+ "vary",
85
+ "www-authenticate",
86
+ "x-api-key",
87
+ "x-forwarded-for",
88
+ "x-request-id",
89
+ ]
90
+ )
91
+ TOKEN = re.compile("^:?[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
92
+
93
+
94
+ def repair_text(text: str) -> str:
95
+ return (
96
+ text.replace("\x00", "")
97
+ .encode("utf-16-le", "surrogatepass")
98
+ .decode("utf-16-le", "replace")
99
+ )
100
+
101
+
102
+ def utf16_len(text: str) -> int:
103
+ return len(text.encode("utf-16-le")) // 2
104
+
105
+
106
+ def truncate_text(text: str, limit: int = 65536) -> str:
107
+ encoded = text.encode("utf-16-le")
108
+ length = len(encoded) // 2
109
+ if length <= limit:
110
+ return text
111
+ separator = "\r\n" if "\r\n" in text else ""
112
+ removed = length - limit
113
+ while True:
114
+ marker = separator + f"[TRUNCATED: {removed} characters removed]"
115
+ kept = max(0, limit - len(marker))
116
+ settled = length - kept
117
+ if settled == removed:
118
+ return encoded[: kept * 2].decode("utf-16-le", "replace") + marker[:limit]
119
+ removed = settled
120
+
121
+
122
+ def json_bytes(value: Any) -> bytes:
123
+ return json.dumps(
124
+ value, ensure_ascii=False, allow_nan=False, separators=(",", ":")
125
+ ).encode("utf-8")
126
+
127
+
128
+ @dataclass
129
+ class Captured:
130
+ value: object
131
+ truncated: bool = False
132
+ omitted: bool = False
133
+ unreadable: bool = False
134
+ names: list[tuple[str, str]] = field(default_factory=list)
135
+ strings_cut: int = 0
136
+ characters_removed: int = 0
137
+
138
+
139
+ class _TooLarge(Exception):
140
+ pass
141
+
142
+
143
+ def looks_secret(name: str, value: object) -> bool:
144
+ if type(value) not in (str, int, float) or type(value) is bool:
145
+ return False
146
+ if type(value) is str and (
147
+ not value
148
+ or value == REDACTED
149
+ or value.strip().lower()
150
+ in ("true", "false", "none", "basic", "bearer", "oauth", "required", "optional")
151
+ ):
152
+ return False
153
+ name = re.sub("v?[0-9]+$", "", fold(name))
154
+ terms = [
155
+ "token",
156
+ "secret",
157
+ "password",
158
+ "passwd",
159
+ "passphrase",
160
+ "passcode",
161
+ "credential",
162
+ "credentials",
163
+ "authorization",
164
+ "auth",
165
+ "bearer",
166
+ "cookie",
167
+ "cookies",
168
+ "signature",
169
+ "jwt",
170
+ "otp",
171
+ "cvv",
172
+ "cvc",
173
+ "apikey",
174
+ "accesskey",
175
+ "secretkey",
176
+ "privatekey",
177
+ "signingkey",
178
+ "encryptionkey",
179
+ "masterkey",
180
+ "sessionkey",
181
+ "authkey",
182
+ "hmackey",
183
+ "sharedkey",
184
+ "subscriptionkey",
185
+ "sessionid",
186
+ "sessid",
187
+ "secretstring",
188
+ "secretvalue",
189
+ "codeverifier",
190
+ "clientassertion",
191
+ "authcode",
192
+ "authorizationcode",
193
+ "otpcode",
194
+ "mfacode",
195
+ "recoverycode",
196
+ "connectionstring",
197
+ "databaseurl",
198
+ "dsn",
199
+ "passwordconfirmation",
200
+ ]
201
+ for term in terms:
202
+ if not name.endswith(term):
203
+ continue
204
+ prefix = name[: -len(term)]
205
+ if term == "token" and prefix.endswith(
206
+ tuple(
207
+ [
208
+ "page",
209
+ "next",
210
+ "continuation",
211
+ "pagination",
212
+ "sync",
213
+ "client",
214
+ "clientrequest",
215
+ "idempotency",
216
+ "resume",
217
+ "cancel",
218
+ "cursor",
219
+ "start",
220
+ "stop",
221
+ "bos",
222
+ "eos",
223
+ "pad",
224
+ "unk",
225
+ "sep",
226
+ "cls",
227
+ "mask",
228
+ ]
229
+ )
230
+ ):
231
+ return False
232
+ if term == "signature" and prefix.endswith("email"):
233
+ return False
234
+ if term == "auth" and type(value) is str and (len(value) < 8):
235
+ return False
236
+ return True
237
+ return (
238
+ name == "hmac"
239
+ or name
240
+ in ("pin",)
241
+ + tuple(
242
+ x + "pin"
243
+ for x in [
244
+ "card",
245
+ "atm",
246
+ "user",
247
+ "account",
248
+ "security",
249
+ "login",
250
+ "new",
251
+ "old",
252
+ "current",
253
+ ]
254
+ )
255
+ or name in tuple(x + "pwd" for x in ["db", "user", "admin", "root", "database"])
256
+ )
257
+
258
+
259
+ def capture(value: object, config: Config, *, field_name: str = "input") -> Captured:
260
+ result = Captured(None)
261
+ any_depth = set(SECRETS)
262
+ paths = []
263
+ if config.capture_mode != "full-payload":
264
+ for rule in config.redact:
265
+ if rule.startswith("**.") and (not any(c in rule[3:] for c in ".*[]")):
266
+ any_depth.add(fold(rule[3:]))
267
+ else:
268
+ paths.append(
269
+ tuple(fold(x) for x in rule.replace("[*]", ".[*]").split("."))
270
+ )
271
+ known = set(config.known_safe_names)
272
+ ancestors = set()
273
+ visited = 0
274
+ budget = 0
275
+
276
+ def matches(path):
277
+ return any(
278
+ len(rule) == len(path)
279
+ and all((r == "*" or r == fold(p) for r, p in zip(rule, path)))
280
+ for rule in paths
281
+ )
282
+
283
+ def observe(name, child, path):
284
+ if (
285
+ fold(name) not in known
286
+ and looks_secret(name, child)
287
+ and (len(result.names) < MAX_SECRET_NAMES)
288
+ ):
289
+ result.names.append(
290
+ (
291
+ name[:MAX_SECRET_NAME_LENGTH],
292
+ ".".join(path).replace(".[*]", "[*]")[:MAX_SECRET_PATH_LENGTH],
293
+ )
294
+ )
295
+
296
+ def text(value):
297
+ fixed = repair_text(value)
298
+ if "\r\n" in fixed or re.search(
299
+ "\\[TRUNCATED: [0-9]+ characters removed\\]$", fixed
300
+ ):
301
+ lines = fixed.split("\r\n")
302
+ done = False
303
+ for i, line in enumerate(lines):
304
+ if not line and i > 0:
305
+ done = True
306
+ if done:
307
+ continue
308
+ match = re.match("^([!#$%&'*+.^_`|~0-9A-Za-z-]+):([ \\t]*)(.*)$", line)
309
+ if match:
310
+ name, space, child = match.groups()
311
+ if child and fold(name) in any_depth:
312
+ lines[i] = name + ":" + space + REDACTED
313
+ elif child:
314
+ observe(name, child, ())
315
+ fixed = "\r\n".join(lines)
316
+ cut = truncate_text(fixed)
317
+ if cut != fixed:
318
+ result.truncated = True
319
+ result.strings_cut += 1
320
+ removed = re.search("\\[TRUNCATED: ([0-9]+) characters removed\\]$", cut)
321
+ if removed:
322
+ result.characters_removed += int(removed[1])
323
+ return cut
324
+
325
+ def walk(value, depth, path):
326
+ nonlocal visited, budget
327
+ visited += 1
328
+ if visited > config.max_event_bytes or depth > 30:
329
+ raise _TooLarge()
330
+ if value is UNSET:
331
+ out = None
332
+ elif value is None or type(value) is bool:
333
+ out = value
334
+ elif type(value) is str:
335
+ out = text(value)
336
+ elif type(value) is int:
337
+ out = value if abs(value) <= 9007199254740991 else str(Decimal(value))
338
+ # A decimal repair must retain every digit. Truncating it would
339
+ # invent a different numeric value, so omit an over-limit payload.
340
+ if type(out) is str and len(out) > 65536:
341
+ raise _TooLarge()
342
+ elif type(value) is float:
343
+ out = value if math.isfinite(value) else None
344
+ elif type(value) in (bytes, bytearray):
345
+ if len(value) > config.max_event_bytes:
346
+ raise _TooLarge()
347
+ # The rendered object occupies this depth, and its strings occupy
348
+ # the next. Apply the same limits to the representation we send.
349
+ return walk(
350
+ {"type": "bytes", "base64": base64.b64encode(value).decode("ascii")},
351
+ depth,
352
+ path,
353
+ )
354
+ elif type(value) in (dt.datetime, dt.date):
355
+ if type(value) is dt.date:
356
+ value = dt.datetime.combine(value, dt.time(), dt.timezone.utc)
357
+ if value.tzinfo is None:
358
+ value = value.replace(tzinfo=dt.timezone.utc)
359
+ out = (
360
+ value.astimezone(dt.timezone.utc)
361
+ .isoformat(timespec="milliseconds")
362
+ .replace("+00:00", "Z")
363
+ )
364
+ elif isinstance(value, Mapping) or type(value) in (tuple, list):
365
+ if id(value) in ancestors:
366
+ return "[CIRCULAR]"
367
+ ancestors.add(id(value))
368
+ try:
369
+ if len(value) > 1000:
370
+ raise _TooLarge()
371
+ if isinstance(value, Mapping):
372
+ out = {}
373
+ for index, key in enumerate(value):
374
+ if index >= 1000:
375
+ raise _TooLarge()
376
+ if type(key) is not str:
377
+ result.unreadable = True
378
+ continue
379
+ fixed = repair_text(key)
380
+ newpath = path + (key,)
381
+ if fold(key) in any_depth or matches(newpath):
382
+ out[fixed] = REDACTED
383
+ continue
384
+ try:
385
+ child = value[key]
386
+ except Exception:
387
+ child = UNCAPTURABLE
388
+ result.unreadable = True
389
+ if child is UNSET:
390
+ continue
391
+ observe(key, child, newpath)
392
+ out[fixed] = walk(child, depth + 1, newpath)
393
+ budget += len(fixed.encode("utf-8"))
394
+ if budget > config.max_event_bytes:
395
+ raise _TooLarge()
396
+ else:
397
+ interleaved = (
398
+ len(value) > 0
399
+ and len(value) % 2 == 0
400
+ and all(type(x) is str for x in value)
401
+ and all(TOKEN.fullmatch(x) for x in value[::2])
402
+ and any(x.lower() in KNOWN_HEADERS for x in value[::2])
403
+ )
404
+ out = []
405
+ for index, child in enumerate(value):
406
+ subpath = path + ("[*]",)
407
+ if matches(subpath):
408
+ out.append(REDACTED)
409
+ continue
410
+ if interleaved and index % 2:
411
+ name = value[index - 1]
412
+ if (
413
+ fold(name) in any_depth
414
+ and child.lower() not in KNOWN_HEADERS
415
+ ):
416
+ out.append(REDACTED)
417
+ continue
418
+ if fold(name) not in any_depth:
419
+ observe(name, child, subpath)
420
+ elif (
421
+ type(child) in (list, tuple)
422
+ and len(child) == 2
423
+ and (type(child[0]) is str)
424
+ ):
425
+ name, val = child
426
+ if fold(name) in any_depth and not (
427
+ type(val) is str and val.lower() in KNOWN_HEADERS
428
+ ):
429
+ out.append([repair_text(name), REDACTED])
430
+ continue
431
+ if fold(name) not in any_depth:
432
+ observe(name, val, subpath)
433
+ elif type(child) is dict and "value" in child:
434
+ name = child.get("name")
435
+ if type(name) is not str:
436
+ name = child.get("key")
437
+ if type(name) is str:
438
+ val = child["value"]
439
+ if fold(name) in any_depth and not (
440
+ type(val) is str and val.lower() in KNOWN_HEADERS
441
+ ):
442
+ child = {**child, "value": REDACTED}
443
+ elif fold(name) not in any_depth:
444
+ observe(name, val, subpath)
445
+ out.append(walk(child, depth + 1, subpath))
446
+ except _TooLarge:
447
+ raise
448
+ except Exception:
449
+ out = UNCAPTURABLE
450
+ result.unreadable = True
451
+ finally:
452
+ ancestors.remove(id(value))
453
+ else:
454
+ out = UNCAPTURABLE
455
+ result.unreadable = True
456
+ budget += len(out.encode("utf-8")) + 2 if type(out) is str else 1
457
+ if budget > config.max_event_bytes:
458
+ raise _TooLarge()
459
+ return out
460
+
461
+ try:
462
+ result.value = walk(value, 0, ())
463
+ except _TooLarge:
464
+ result.value = TOO_LARGE
465
+ result.omitted = True
466
+ result.truncated = False
467
+ result.names = []
468
+ except Exception:
469
+ result.value = UNCAPTURABLE
470
+ result.unreadable = True
471
+ result.truncated = False
472
+ result.names = []
473
+ return result