proxy2model 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.
Files changed (66) hide show
  1. proxy2model/__init__.py +19 -0
  2. proxy2model/__main__.py +6 -0
  3. proxy2model/atomic.py +68 -0
  4. proxy2model/cli/__init__.py +3 -0
  5. proxy2model/cli/analyze.py +501 -0
  6. proxy2model/cli/app.py +408 -0
  7. proxy2model/cli/browse.py +279 -0
  8. proxy2model/cli/build.py +399 -0
  9. proxy2model/cli/common.py +190 -0
  10. proxy2model/cli/history.py +224 -0
  11. proxy2model/cli/mcp.py +184 -0
  12. proxy2model/cli/ui.py +62 -0
  13. proxy2model/core/__init__.py +1 -0
  14. proxy2model/core/changes.py +124 -0
  15. proxy2model/core/context.py +571 -0
  16. proxy2model/core/diff.py +330 -0
  17. proxy2model/core/domains.py +98 -0
  18. proxy2model/core/graph.py +562 -0
  19. proxy2model/core/methodology.py +343 -0
  20. proxy2model/core/model.py +998 -0
  21. proxy2model/core/stack.py +218 -0
  22. proxy2model/data/fonts/LICENSES.md +16 -0
  23. proxy2model/data/fonts/inter-latin-wght-normal.woff2 +0 -0
  24. proxy2model/data/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  25. proxy2model/data/fonts/nippo-200.woff2 +0 -0
  26. proxy2model/data/fonts/nippo-300.woff2 +0 -0
  27. proxy2model/data/fonts/nippo-400.woff2 +0 -0
  28. proxy2model/data/fonts/nippo-500.woff2 +0 -0
  29. proxy2model/data/fonts/nippo-700.woff2 +0 -0
  30. proxy2model/data/public_suffix_list.dat +16497 -0
  31. proxy2model/db/__init__.py +1 -0
  32. proxy2model/db/bql.py +710 -0
  33. proxy2model/db/bql_js.py +278 -0
  34. proxy2model/db/store.py +547 -0
  35. proxy2model/export/__init__.py +1 -0
  36. proxy2model/export/openapi.py +248 -0
  37. proxy2model/ingest/__init__.py +1 -0
  38. proxy2model/ingest/browse.py +181 -0
  39. proxy2model/ingest/browser.py +1058 -0
  40. proxy2model/ingest/crawl.py +673 -0
  41. proxy2model/ingest/extract.py +208 -0
  42. proxy2model/ingest/infer.py +75 -0
  43. proxy2model/ingest/journey.py +148 -0
  44. proxy2model/ingest/live.py +96 -0
  45. proxy2model/ingest/parse.py +1275 -0
  46. proxy2model/ingest/redact.py +535 -0
  47. proxy2model/py.typed +0 -0
  48. proxy2model/recon/__init__.py +1 -0
  49. proxy2model/recon/osint.py +607 -0
  50. proxy2model/report/__init__.py +1 -0
  51. proxy2model/report/animate.py +163 -0
  52. proxy2model/report/dash.py +469 -0
  53. proxy2model/report/fonts.py +51 -0
  54. proxy2model/report/html.py +988 -0
  55. proxy2model/report/inventory.py +258 -0
  56. proxy2model/report/map.py +515 -0
  57. proxy2model/report/pages.py +537 -0
  58. proxy2model/report/query.py +306 -0
  59. proxy2model/report/shot.py +55 -0
  60. proxy2model/report/view.py +213 -0
  61. proxy2model-2.0.0.dist-info/METADATA +148 -0
  62. proxy2model-2.0.0.dist-info/RECORD +66 -0
  63. proxy2model-2.0.0.dist-info/WHEEL +5 -0
  64. proxy2model-2.0.0.dist-info/entry_points.txt +2 -0
  65. proxy2model-2.0.0.dist-info/licenses/LICENSE +201 -0
  66. proxy2model-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1275 @@
1
+ """
2
+ Parse captured traffic into redacted HTTP exchanges.
3
+
4
+ Every input (a Burp "Save items" XML, a Logger++ CSV, a HAR, a live push, a crawl) is read as
5
+ Burp-style <item> elements, so one parser and one redaction path serve them all.
6
+
7
+ Burp stores each proxied request/response as an <item> with base64-encoded
8
+ request and response blobs. This module streams the file (so multi-GB exports
9
+ don't blow memory), decodes each blob, splits head/body, and hands back a
10
+ plain dataclass with every value already redacted (see redact.py).
11
+
12
+ Everything the model needs from a *body* (endpoint references in JS/HTML,
13
+ GraphQL operation names) is extracted here, from the full body. What survives
14
+ of a body is the fully redacted, capped excerpt in `ev` — the report's
15
+ request/response panes — with every value masked by name and by shape.
16
+
17
+ Nothing here writes to disk. Redaction happens before an Exchange leaves this
18
+ module, so no raw secret ever reaches the model or the report.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import base64
24
+ import codecs
25
+ import json
26
+ import os
27
+ import re
28
+ import urllib.parse
29
+ import zlib
30
+ from dataclasses import dataclass, field
31
+ from xml.etree.ElementTree import iterparse
32
+
33
+ from .infer import param_types, value_type
34
+ from .extract import MAX_SCAN_BYTES, extract_html_refs, extract_js_refs, extract_script_assets
35
+ from ..core.stack import detect as detect_stack
36
+ from .redact import (
37
+ REDACTED,
38
+ SecretFingerprint,
39
+ classify_segment,
40
+ redact_body,
41
+ redact_headers,
42
+ redact_named,
43
+ redact_params,
44
+ redact_path,
45
+ route_template,
46
+ template_path, # noqa: F401 (re-exported: parse.template_path is public API)
47
+ )
48
+
49
+ BODY_KEEP = 20000
50
+ CODE_KEEP = 40000 # a script the report shows beautified: keep more of it
51
+
52
+ _SESSION_COOKIE_HINTS = ("sess", "sid", "auth", "token", "jwt", "remember", "login", "user")
53
+ _CREDENTIAL_HEADER_HINTS = ("api-key", "apikey", "auth-token", "access-token", "x-token")
54
+ _TECH_HEADERS = ("server", "x-powered-by", "x-aspnet-version", "x-aspnetmvc-version",
55
+ "x-generator", "x-runtime", "x-framework")
56
+
57
+
58
+ def ev_pack(ev: dict) -> bytes:
59
+ """The request and response panes as compressed JSON: redacted text packs ~5x, and a large capture
60
+ otherwise holds every pane as Python dicts and lists. Empty stays empty."""
61
+ if not ev:
62
+ return b""
63
+ return zlib.compress(json.dumps({"request": ev.get("request"), "response": ev.get("response")},
64
+ ensure_ascii=False).encode("utf-8"), 1)
65
+
66
+
67
+ def ev_unpack(z: bytes) -> dict:
68
+ return json.loads(zlib.decompress(z).decode("utf-8")) if z else {}
69
+
70
+
71
+ @dataclass
72
+ class Exchange:
73
+ """One redacted request/response pair."""
74
+ index: int
75
+ method: str
76
+ scheme: str
77
+ host: str
78
+ port: int
79
+ path: str # redacted path incl. redacted query
80
+ path_template: str # /api/users/{id}
81
+ status: int | None
82
+ mime: str
83
+ req_headers: list[tuple[str, str]] = field(default_factory=list)
84
+ resp_headers: list[tuple[str, str]] = field(default_factory=list)
85
+ query_params: list[tuple[str, str]] = field(default_factory=list)
86
+ body_params: list[tuple[str, str]] = field(default_factory=list)
87
+ req_body: str = ""
88
+ resp_body: str = ""
89
+ role: str | None = None
90
+ secrets: list[SecretFingerprint] = field(default_factory=list)
91
+ # provenance: which export file and which <item> in it
92
+ source: str = ""
93
+ item: int = -1
94
+ # metadata extracted before redaction (names and shapes only)
95
+ referer_host: str | None = None
96
+ referer_path: str | None = None
97
+ cookie_names: list[str] = field(default_factory=list)
98
+ set_cookies: list[dict] = field(default_factory=list)
99
+ credentials: list[str] = field(default_factory=list)
100
+ tech: list[tuple[str, str]] = field(default_factory=list)
101
+ js_refs: list[tuple] = field(default_factory=list)
102
+ js_assets: list[tuple] = field(default_factory=list) # source maps, workers a script names
103
+ stack: list[tuple] = field(default_factory=list) # (name, category, how, version) the response shows
104
+ scan_truncated: bool = False
105
+ gql_ops: list[str] = field(default_factory=list)
106
+ preflight: bool = False
107
+ # (location, name, type) read from the raw values before masking: labels only
108
+ param_types: list[tuple] = field(default_factory=list)
109
+ # (name, type) of each field of a JSON response (`$.user.id`, "integer"): names and types only
110
+ resp_fields: list[tuple] = field(default_factory=list)
111
+ # a bot-detection or challenge vendor's own traffic (see _bot_beacon): evidence, not the app's API
112
+ beacon: str = ""
113
+ # a fully redacted view of the exchange, for the report's request/response
114
+ # panes: request line, headers and body all masked; values never present.
115
+ # kept compressed (see ev_pack); `ev` reads and writes it as a dict
116
+ ev_z: bytes = b""
117
+
118
+ @property
119
+ def ev(self) -> dict:
120
+ return ev_unpack(self.ev_z)
121
+
122
+ @ev.setter
123
+ def ev(self, value: dict) -> None:
124
+ self.ev_z = ev_pack(value)
125
+
126
+ @property
127
+ def url(self) -> str:
128
+ return f"{self.scheme}://{self.host}{self.path}"
129
+
130
+ @property
131
+ def endpoint_id(self) -> str:
132
+ return f"{self.method}:{self.host}:{self.path_template}"
133
+
134
+ # ---- persistence: names, shapes and the redacted `ev` panes — never a
135
+ # raw value; `ev` bodies are masked by name and shape and capped ----
136
+ def to_record(self) -> dict:
137
+ return {
138
+ "index": self.index, "source": self.source, "item": self.item,
139
+ "method": self.method, "scheme": self.scheme, "host": self.host,
140
+ "port": self.port, "path": self.path, "path_template": self.path_template,
141
+ "status": self.status, "mime": self.mime, "role": self.role,
142
+ "query_params": [n for n, _ in self.query_params],
143
+ "body_params": [n for n, _ in self.body_params],
144
+ "secrets": [s.as_dict() for s in self.secrets],
145
+ "referer_host": self.referer_host, "referer_path": self.referer_path,
146
+ "cookie_names": self.cookie_names, "set_cookies": self.set_cookies,
147
+ "credentials": self.credentials, "tech": [list(t) for t in self.tech],
148
+ "js_refs": [list(r) for r in self.js_refs],
149
+ "js_assets": [list(r) for r in self.js_assets],
150
+ "stack": [list(r) for r in self.stack],
151
+ "scan_truncated": self.scan_truncated, "gql_ops": self.gql_ops,
152
+ "preflight": self.preflight, "ev": self.ev,
153
+ "param_types": [list(t) for t in self.param_types],
154
+ "resp_fields": [list(t) for t in self.resp_fields], "beacon": self.beacon,
155
+ }
156
+
157
+ @classmethod
158
+ def from_record(cls, r: dict) -> "Exchange":
159
+ return cls(
160
+ index=r["index"], method=r["method"], scheme=r["scheme"], host=r["host"],
161
+ port=r["port"], path=r["path"], path_template=r["path_template"],
162
+ status=r["status"], mime=r["mime"], role=r.get("role"),
163
+ query_params=[(n, "") for n in r.get("query_params", [])],
164
+ body_params=[(n, "") for n in r.get("body_params", [])],
165
+ secrets=[SecretFingerprint(s["kind"], s["hmac_12"], s["length"], s["entropy"])
166
+ for s in r.get("secrets", [])],
167
+ source=r.get("source", ""), item=r.get("item", -1),
168
+ referer_host=r.get("referer_host"), referer_path=r.get("referer_path"),
169
+ cookie_names=r.get("cookie_names", []), set_cookies=r.get("set_cookies", []),
170
+ credentials=r.get("credentials", []),
171
+ tech=[tuple(t) for t in r.get("tech", [])],
172
+ js_refs=[tuple(x) for x in r.get("js_refs", [])],
173
+ js_assets=[tuple(x) for x in r.get("js_assets", [])],
174
+ stack=[tuple(x) for x in r.get("stack", [])],
175
+ scan_truncated=r.get("scan_truncated", False),
176
+ gql_ops=r.get("gql_ops", []), preflight=r.get("preflight", False),
177
+ param_types=[tuple(t) for t in r.get("param_types", [])],
178
+ resp_fields=[tuple(t) for t in r.get("resp_fields", [])], beacon=r.get("beacon", ""),
179
+ ev_z=ev_pack(r.get("ev", {})),
180
+ )
181
+
182
+
183
+ def _b64(text: str | None, is_b64: bool) -> bytes:
184
+ if text is None:
185
+ return b""
186
+ if is_b64:
187
+ try:
188
+ return base64.b64decode(text)
189
+ except Exception:
190
+ pass
191
+ # whitespace, missing padding or a cut-off tail (a spreadsheet cell limit): decode what is there
192
+ t = re.sub(r"\s+", "", text)
193
+ if len(t) % 4 == 1:
194
+ t = t[:-1]
195
+ try:
196
+ return base64.b64decode(t + "=" * (-len(t) % 4))
197
+ except Exception:
198
+ return text.encode("utf-8", "replace")
199
+ return text.encode("utf-8", "replace")
200
+
201
+
202
+ # Cap decompressed size so a crafted export can't act as a decompression bomb.
203
+ _MAX_DECOMPRESS = 50 * 1024 * 1024
204
+
205
+
206
+ def _dechunk(body: bytes) -> bytes:
207
+ """Reassemble a Transfer-Encoding: chunked body; return input on any error."""
208
+ out, pos = [], 0
209
+ try:
210
+ while pos < len(body):
211
+ eol = body.index(b"\r\n", pos)
212
+ size = int(body[pos:eol].split(b";", 1)[0], 16)
213
+ if size == 0:
214
+ break
215
+ chunk = body[eol + 2:eol + 2 + size]
216
+ if len(chunk) < size:
217
+ return body
218
+ out.append(chunk)
219
+ pos = eol + 2 + size + 2
220
+ if sum(map(len, out)) > _MAX_DECOMPRESS:
221
+ break
222
+ return b"".join(out)
223
+ except (ValueError, IndexError):
224
+ return body
225
+
226
+
227
+ def _decompress(body: bytes, encoding: str) -> tuple[bytes, bool]:
228
+ """Decompress a body, bounded. Returns (body, handled) — handled is False
229
+ when the content-encoding is one we cannot decode (br/zstd without the
230
+ optional library), so callers can flag the blind spot instead of silently
231
+ scanning compressed bytes."""
232
+ enc = ",".join(t.strip() for t in encoding.lower().split(","))
233
+ try:
234
+ if body[:2] == b"\x1f\x8b":
235
+ d = zlib.decompressobj(16 + zlib.MAX_WBITS)
236
+ return d.decompress(body, _MAX_DECOMPRESS), True
237
+ if "deflate" in enc:
238
+ try:
239
+ return zlib.decompressobj().decompress(body, _MAX_DECOMPRESS), True
240
+ except zlib.error:
241
+ return zlib.decompressobj(-zlib.MAX_WBITS).decompress(body, _MAX_DECOMPRESS), True
242
+ if "br" in enc.split(","):
243
+ try:
244
+ import brotli # type: ignore
245
+ return brotli.decompress(body)[:_MAX_DECOMPRESS], True
246
+ except ImportError:
247
+ return body, not body
248
+ if "zstd" in enc:
249
+ try:
250
+ import zstandard # type: ignore
251
+ return zstandard.ZstdDecompressor().decompress(
252
+ body, max_output_size=_MAX_DECOMPRESS), True
253
+ except ImportError:
254
+ return body, not body
255
+ except Exception:
256
+ return body, True
257
+ return body, True
258
+
259
+
260
+ def _split_http(raw: bytes) -> tuple[str, bytes]:
261
+ """Split HTTP message into head (text) and body (bytes)."""
262
+ sep = raw.find(b"\r\n\r\n")
263
+ if sep == -1:
264
+ sep = raw.find(b"\n\n")
265
+ head, body = (raw[:sep], raw[sep + 2:]) if sep != -1 else (raw, b"")
266
+ else:
267
+ head, body = raw[:sep], raw[sep + 4:]
268
+ return head.decode("latin-1", "replace"), body
269
+
270
+
271
+ def _parse_headers(head: str) -> tuple[str, list[tuple[str, str]]]:
272
+ lines = head.split("\n")
273
+ start = lines[0].strip() if lines else ""
274
+ headers: list[tuple[str, str]] = []
275
+ for line in lines[1:]:
276
+ line = line.rstrip("\r")
277
+ if not line or ":" not in line:
278
+ continue
279
+ name, _, value = line.partition(":")
280
+ headers.append((name.strip(), value.strip()))
281
+ return start, headers
282
+
283
+
284
+ def _header(headers, name: str) -> str:
285
+ low = name.lower()
286
+ return next((v for k, v in headers if k.lower() == low), "")
287
+
288
+
289
+ def _parse_query(path: str) -> list[tuple[str, str]]:
290
+ if "?" not in path:
291
+ return []
292
+ q = path.split("?", 1)[1].split("#", 1)[0]
293
+ return [(k, v) for k, v in urllib.parse.parse_qsl(q, keep_blank_values=True)]
294
+
295
+
296
+ def _parse_body_params(body: str, content_type: str) -> list[tuple[str, str]]:
297
+ ct = content_type.lower()
298
+ if "application/x-www-form-urlencoded" in ct:
299
+ return [(k, v) for k, v in urllib.parse.parse_qsl(body, keep_blank_values=True)]
300
+ if "json" in ct:
301
+ try:
302
+ data = json.loads(body)
303
+ except Exception:
304
+ return []
305
+ out: list[tuple[str, str]] = []
306
+
307
+ def walk(obj, prefix="$", depth=0):
308
+ if depth > 12:
309
+ return
310
+ if isinstance(obj, dict):
311
+ for k, v in obj.items():
312
+ # a key can itself be a value (an email, a token used as a
313
+ # map key); collapse it so it never becomes a param name
314
+ kk = classify_segment(str(k)[:200]) or str(k)[:200]
315
+ walk(v, f"{prefix}.{kk}", depth + 1)
316
+ elif isinstance(obj, list):
317
+ for v in obj[:5]: # items share one name: `$.events[].id`
318
+ walk(v, f"{prefix}[]", depth + 1)
319
+ else:
320
+ out.append((prefix, str(obj)))
321
+
322
+ walk(data)
323
+ return out
324
+ return []
325
+
326
+
327
+ _GQL_OP_RE = re.compile(r"^\s*(query|mutation|subscription)\s+([A-Za-z_][A-Za-z0-9_]*)")
328
+
329
+
330
+ # Bot-detection and challenge vendors post their sensor data to the app's own host, often on a
331
+ # random-looking path. Matched on the raw request; the label names the vendor, nothing more.
332
+ _BEACON_PATHS = (
333
+ (re.compile(r"^/cdn-cgi/(challenge-platform|rum|bm/cv|trace)(/|$)"), "cloudflare"),
334
+ (re.compile(r"^/_sec/(cp_challenge|verify)"), "akamai"),
335
+ (re.compile(r"/api/v2/collector(/|$|\?)"), "perimeterx"),
336
+ (re.compile(r"^/_Incapsula_Resource"), "imperva"),
337
+ )
338
+
339
+
340
+ def _bot_beacon(path: str, req_headers, body: str) -> str:
341
+ if body[:200].lstrip().startswith('{"sensor_data"'):
342
+ return "akamai"
343
+ if any(k.lower().startswith("x-kpsdk-") for k, _ in req_headers):
344
+ return "kasada"
345
+ for rx, vendor in _BEACON_PATHS:
346
+ if rx.search(path):
347
+ return vendor
348
+ return ""
349
+
350
+
351
+ _RESP_FIELDS_MAX = 200
352
+
353
+
354
+ def _resp_fields(body: bytes, content_type: str) -> list[tuple[str, str]]:
355
+ """(JSONPath name, type) of each leaf of a JSON response. Types come from the raw values,
356
+ which are then dropped; object keys that look like values become `{key}`."""
357
+ head = body[:1].strip() if body else b""
358
+ if not body or len(body) > 2 * 1024 * 1024 or not ("json" in content_type.lower() or head in (b"{", b"[")):
359
+ return []
360
+ try:
361
+ data = json.loads(body)
362
+ except ValueError:
363
+ return []
364
+ out: dict[str, str] = {}
365
+
366
+ def walk(obj, prefix="$", depth=0):
367
+ if depth > 8 or len(out) >= _RESP_FIELDS_MAX:
368
+ return
369
+ if isinstance(obj, dict):
370
+ for k, v in obj.items():
371
+ k = str(k)[:80]
372
+ key = k if value_type(k) == "string" and not classify_segment(k) else "{key}"
373
+ walk(v, f"{prefix}.{key}", depth + 1)
374
+ elif isinstance(obj, list):
375
+ for v in obj[:5]:
376
+ walk(v, f"{prefix}[]", depth + 1)
377
+ elif obj is not None:
378
+ t = ("boolean" if isinstance(obj, bool) else "integer" if isinstance(obj, int)
379
+ else "number" if isinstance(obj, float) else value_type(str(obj)) or "string")
380
+ out.setdefault(prefix, t)
381
+
382
+ walk(data)
383
+ return sorted(out.items())
384
+
385
+
386
+ def _gql_ops(path_template: str, body: str, ct: str) -> list[str]:
387
+ if "graphql" not in path_template.lower() and "gql" not in path_template.lower():
388
+ return []
389
+ try:
390
+ data = json.loads(body) if body and "json" in ct.lower() else None
391
+ except Exception:
392
+ data = None
393
+ ops: list[str] = []
394
+ for obj in (data if isinstance(data, list) else [data]):
395
+ if not isinstance(obj, dict):
396
+ continue
397
+ name = obj.get("operationName")
398
+ if not name and isinstance(obj.get("query"), str):
399
+ m = _GQL_OP_RE.match(obj["query"])
400
+ name = m.group(2) if m else None
401
+ if isinstance(name, str) and re.match(r"^[A-Za-z_][A-Za-z0-9_]{0,80}$", name):
402
+ ops.append(name)
403
+ return ops
404
+
405
+
406
+ def _credentials(req_headers, cookie_names, query_params) -> list[str]:
407
+ creds: list[str] = []
408
+ auth = _header(req_headers, "authorization")
409
+ if auth:
410
+ scheme = auth.split(" ", 1)[0].lower() if " " in auth.strip() else "custom"
411
+ creds.append(scheme if re.match(r"^[a-z0-9-]{2,30}$", scheme) else "custom")
412
+ for k, _ in req_headers:
413
+ low = k.lower()
414
+ if low != "authorization" and any(h in low for h in _CREDENTIAL_HEADER_HINTS):
415
+ creds.append(f"header:{low}")
416
+ for c in cookie_names:
417
+ if any(h in c.lower() for h in _SESSION_COOKIE_HINTS):
418
+ creds.append(f"cookie:{c}")
419
+ for k, _ in query_params:
420
+ low = k.lower()
421
+ if low in ("api_key", "apikey", "access_token", "token", "key", "auth"):
422
+ creds.append(f"query:{low}")
423
+ return sorted(set(creds))
424
+
425
+
426
+ def _cookie_names(cookie_header: str) -> list[str]:
427
+ names = []
428
+ for part in cookie_header.split(";"):
429
+ name = part.split("=", 1)[0].strip()
430
+ if name and re.match(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]{1,100}$", name):
431
+ names.append(name)
432
+ return names
433
+
434
+
435
+ def _exchange_secrets(req_headers, resp_headers) -> list[str]:
436
+ """Cookie and credential values this exchange sent or set, longest first.
437
+
438
+ Masking by name catches them in their own header; a server can echo the same value
439
+ elsewhere (a session id in a body, a deployment cookie in `X-Deployment-Id`), so they are
440
+ masked wherever they appear in the exchange. Short values (`1`, `en`) are left alone."""
441
+ vals: set[str] = set()
442
+ for k, v in req_headers:
443
+ low = k.lower()
444
+ if low == "cookie":
445
+ vals |= {p.split("=", 1)[1].strip() for p in v.split(";") if "=" in p}
446
+ elif low == "authorization" or any(h in low for h in _CREDENTIAL_HEADER_HINTS):
447
+ vals |= {v.strip(), v.strip().split(" ")[-1]}
448
+ for k, v in resp_headers:
449
+ if k.lower() == "set-cookie" and "=" in v:
450
+ vals.add(v.split(";", 1)[0].split("=", 1)[1].strip())
451
+ out = set()
452
+ for x in vals:
453
+ if len(x) >= 8:
454
+ out |= {x, urllib.parse.quote(x, safe=""), urllib.parse.unquote(x)}
455
+ return sorted((x for x in out if len(x) >= 8), key=len, reverse=True)
456
+
457
+
458
+ def _scrubber(values: list[str]):
459
+ if not values:
460
+ return lambda text: text
461
+
462
+ def scrub(text: str) -> str:
463
+ for v in values:
464
+ if v in text:
465
+ text = text.replace(v, REDACTED)
466
+ return text
467
+ return scrub
468
+
469
+
470
+ def _set_cookies(resp_headers) -> list[dict]:
471
+ out = []
472
+ for k, v in resp_headers:
473
+ if k.lower() != "set-cookie" or "=" not in v:
474
+ continue
475
+ first, *attrs = v.split(";")
476
+ name = first.split("=", 1)[0].strip()
477
+ if not name:
478
+ continue
479
+ flags = {a.strip().split("=", 1)[0].lower(): a.strip() for a in attrs}
480
+ samesite = flags.get("samesite", "")
481
+ out.append({
482
+ "name": name[:100],
483
+ "httponly": "httponly" in flags,
484
+ "secure": "secure" in flags,
485
+ "samesite": samesite.split("=", 1)[1].strip() if "=" in samesite else None,
486
+ })
487
+ return out
488
+
489
+
490
+ def _referer(req_headers) -> tuple[str | None, str | None]:
491
+ ref = _header(req_headers, "referer")
492
+ if not ref:
493
+ return None, None
494
+ try:
495
+ parts = urllib.parse.urlsplit(ref)
496
+ except ValueError:
497
+ return None, None
498
+ host = (parts.hostname or "").lower() or None
499
+ if not host:
500
+ return None, None
501
+ path = (parts.path or "/") + ("?" + parts.query if parts.query else "")
502
+ red, _ = redact_path(path)
503
+ return host, route_template(red, template_path(parts.path or "/"))
504
+
505
+
506
+ def _is_js_response(mime: str, path_template: str, ct: str) -> bool:
507
+ m = (mime + " " + ct).lower()
508
+ return "script" in m or "ecmascript" in m or path_template.endswith((".js", ".mjs"))
509
+
510
+
511
+ def _is_html_response(mime: str, ct: str) -> bool:
512
+ return "html" in (mime + " " + ct).lower()
513
+
514
+
515
+ def _text(elem) -> str | None:
516
+ return elem.text if elem is not None else None
517
+
518
+
519
+ _XML_ENCODING = re.compile(rb"""^<\?xml[^>]*?\bencoding\s*=\s*["']([A-Za-z0-9._-]+)["']""")
520
+ # encodings in which `<!ENTITY` is the same bytes as in ASCII, so the raw file can be scanned as is
521
+ _ASCII_SAFE = {"utf-8", "ascii", "latin-1", "iso8859-1", "cp1252"}
522
+
523
+
524
+ def _xml_encoding(head: bytes) -> str:
525
+ """The encoding the XML parser will read the file in: its byte-order mark, its first bytes, or its declaration."""
526
+ for bom, name in ((codecs.BOM_UTF32_LE, "utf-32-le"), (codecs.BOM_UTF32_BE, "utf-32-be"),
527
+ (codecs.BOM_UTF8, "utf-8"), (codecs.BOM_UTF16_LE, "utf-16-le"),
528
+ (codecs.BOM_UTF16_BE, "utf-16-be")):
529
+ if head.startswith(bom):
530
+ return name
531
+ if head.startswith(b"<\0?\0"):
532
+ return "utf-16-le"
533
+ if head.startswith(b"\0<\0?"):
534
+ return "utf-16-be"
535
+ m = _XML_ENCODING.match(head)
536
+ return m.group(1).decode("ascii") if m else "utf-8"
537
+
538
+
539
+ def _check_no_entities(path: str) -> None:
540
+ """Refuse exports that declare XML entities (billion-laughs / XXE shapes).
541
+
542
+ Burp's own exports declare elements and attributes, never entities. The
543
+ whole file is scanned in chunks, so a declaration cannot hide past a
544
+ header-only check. `<!ENTITY` cannot occur inside Burp's base64 payloads.
545
+ A file in UTF-16 or another encoding the parser accepts is decoded first,
546
+ so the declaration cannot hide in bytes that are not ASCII.
547
+ """
548
+ with open(path, "rb") as f:
549
+ head = f.read(1024)
550
+ name = _xml_encoding(head)
551
+ try:
552
+ codec = codecs.lookup(name)
553
+ except LookupError:
554
+ raise ValueError(f"{path}: unknown XML encoding {name!r}")
555
+ decode = (None if codec.name in _ASCII_SAFE
556
+ else codecs.getincrementaldecoder(codec.name)(errors="replace").decode)
557
+ f.seek(0)
558
+ needle, tail = (b"<!ENTITY", b"") if decode is None else ("<!ENTITY", "")
559
+ while True:
560
+ chunk = f.read(1 << 20)
561
+ text = chunk if decode is None else decode(chunk, final=not chunk)
562
+ if needle in (tail + text).upper():
563
+ raise ValueError(
564
+ f"{path}: XML entity declarations are not allowed in a Burp export")
565
+ if not chunk:
566
+ break
567
+ tail = (tail + text)[-8:]
568
+
569
+
570
+ def parse_items(path: str, role: str | None = None, stats: dict | None = None,
571
+ skip_tools: tuple = (), only_tools: tuple = ()):
572
+ """Yield redacted Exchange objects from a Burp export. Streaming.
573
+
574
+ Takes a Burp "Save items" XML, a Logger++ CSV, a HAR or a Caido JSON/CSV export (chosen
575
+ by the file extension and its first bytes). If `stats` is given it is filled with
576
+ items/parsed/skipped counts. `skip_tools` / `only_tools` filter a Logger++ CSV by its
577
+ Tool column, and a Caido export by its source.
578
+ """
579
+ if stats is None:
580
+ stats = {}
581
+ for k in ("items", "parsed", "skipped"):
582
+ stats.setdefault(k, 0)
583
+ if path.lower().endswith(".csv"):
584
+ caido = _caido_csv_header(path)
585
+ if caido is not None:
586
+ yield from _parse_caido(path, role, stats, skip_tools, only_tools, caido)
587
+ return
588
+ yield from _parse_logger_csv(path, role, stats, skip_tools, only_tools)
589
+ return
590
+ if _looks_like_caido_json(path):
591
+ yield from _parse_caido(path, role, stats, skip_tools, only_tools)
592
+ return
593
+ if path.lower().endswith(".har") or _looks_like_har(path):
594
+ yield from _parse_har(path, role, stats)
595
+ return
596
+ _check_no_entities(path)
597
+ source = os.path.basename(path)
598
+ idx = 1 # evidence ids start at 1
599
+ item_no = 0
600
+ root = None
601
+ for event, elem in iterparse(path, events=("start", "end")):
602
+ if event == "start":
603
+ if root is None:
604
+ root = elem
605
+ continue
606
+ if elem.tag != "item":
607
+ continue
608
+ stats["items"] += 1
609
+ try:
610
+ ex = _parse_item(elem, idx, role)
611
+ if ex is None:
612
+ stats["skipped"] += 1
613
+ else:
614
+ ex.source, ex.item = source, item_no
615
+ idx += 1
616
+ stats["parsed"] += 1
617
+ yield ex
618
+ except Exception:
619
+ stats["skipped"] += 1
620
+ finally:
621
+ item_no += 1
622
+ elem.clear()
623
+ # drop the processed <item> from the tree, or the root keeps every
624
+ # cleared element alive and memory grows with the export
625
+ if root is not None:
626
+ root.clear()
627
+
628
+
629
+ # ---------------------------------------------------------------- Logger++ CSV --
630
+
631
+ _CSV_NEEDED = ("Method", "Host", "Request", "Response")
632
+ # A spreadsheet cell holds at most 32,767 characters. A Logger++ CSV that has been through one
633
+ # has its longer responses cut there, with the rest spilling onto following rows.
634
+ _CELL_LIMIT = 32000
635
+
636
+
637
+ def _csv_rows(path: str):
638
+ """Rows of a Logger++ CSV as dicts, with spilled-over cells put back together."""
639
+ import csv
640
+ csv.field_size_limit(2 ** 31 - 1)
641
+ with open(path, newline="", encoding="utf-8", errors="replace") as fh:
642
+ reader = csv.reader(fh)
643
+ try:
644
+ header = [h.strip() for h in next(reader)]
645
+ except StopIteration:
646
+ return
647
+ missing = [c for c in _CSV_NEEDED if c not in header]
648
+ if missing:
649
+ raise ValueError(f"not a Logger++ CSV export (missing columns: {', '.join(missing)})")
650
+ idx = {h: i for i, h in enumerate(header)}
651
+ pending: dict | None = None
652
+ spill: list[str] = [] # continuation cells, joined once (adding them one by one is n^2)
653
+
654
+ def done(row: dict) -> dict:
655
+ if spill:
656
+ field = "Response" if row["Response"] else "Request"
657
+ row[field] = row[field] + "".join(spill)
658
+ spill.clear()
659
+ return row
660
+
661
+ for row in reader:
662
+ if not any(c.strip() for c in row):
663
+ continue
664
+ row = row + [""] * (len(header) - len(row))
665
+ first = row[0].strip()
666
+ rest_empty = not any(c.strip() for c in row[1:])
667
+ # a continuation: only the first cell is filled, and it is not a numeric request id
668
+ if pending is not None and rest_empty and first and not first.isdigit():
669
+ spill.append(first) # to the Response, or to the Request when the Response cell is empty
670
+ pending["_spilled"] = True
671
+ continue
672
+ if pending is not None:
673
+ yield done(pending)
674
+ pending = {h: row[i] for h, i in idx.items()}
675
+ pending["_spilled"] = False
676
+ if pending is not None:
677
+ yield done(pending)
678
+
679
+
680
+ def _b64_text(value: str) -> tuple[str, bool]:
681
+ """(`true`/`false` for the base64 attribute, text). Logger++ writes raw HTTP as base64."""
682
+ v = value.strip()
683
+ if not v:
684
+ return "false", ""
685
+ if v.startswith(("GET ", "POST ", "PUT ", "HEAD ", "HTTP/", "OPTIONS ", "DELETE ", "PATCH ")):
686
+ return "false", value
687
+ return "true", re.sub(r"\s+", "", v)
688
+
689
+
690
+ def _parse_logger_csv(path, role, stats, skip_tools, only_tools):
691
+ source = os.path.basename(path)
692
+ skip = {t.strip().lower() for t in skip_tools if t.strip()}
693
+ only = {t.strip().lower() for t in only_tools if t.strip()}
694
+ tools: dict[str, int] = stats.setdefault("tools", {})
695
+ from xml.etree.ElementTree import Element, SubElement
696
+ idx = 1 # evidence ids start at 1
697
+ for item_no, row in enumerate(_csv_rows(path)):
698
+ tool = (row.get("Tool") or "").strip() or "?"
699
+ tools[tool] = tools.get(tool, 0) + 1
700
+ if (skip and tool.lower() in skip) or (only and tool.lower() not in only):
701
+ stats["filtered"] = stats.get("filtered", 0) + 1
702
+ continue
703
+ stats["items"] += 1
704
+ try:
705
+ item = Element("item")
706
+ qs = (row.get("Query") or "").strip()
707
+ p = (row.get("Path") or "").strip()
708
+ if not p:
709
+ u = urllib.parse.urlsplit(row.get("URL") or "")
710
+ p, qs = (u.path or "/") + ("?" + u.query if u.query else ""), ""
711
+ if qs and "?" not in p:
712
+ p = p + "?" + qs.lstrip("?")
713
+ for tag, val in (("method", row.get("Method")), ("host", row.get("Host")),
714
+ ("port", row.get("Port")), ("protocol", row.get("Protocol")), ("path", p),
715
+ ("status", row.get("Status code")), ("mimetype", row.get("MIME type"))):
716
+ SubElement(item, tag).text = (val or "").strip()
717
+ for tag in ("Request", "Response"):
718
+ b64, text = _b64_text(row.get(tag) or "")
719
+ SubElement(item, tag.lower(), {"base64": b64}).text = text
720
+ # a response that stops at the spreadsheet cell limit and never recovered is cut short
721
+ if not row.get("_spilled") and _CELL_LIMIT <= len(row.get("Response") or "") < 32768:
722
+ SubElement(item, "truncated").text = "1"
723
+ ex = _parse_item(item, idx, role)
724
+ except Exception:
725
+ ex = None
726
+ if ex is None:
727
+ stats["skipped"] += 1
728
+ continue
729
+ ex.source, ex.item = source, item_no
730
+ idx += 1
731
+ stats["parsed"] += 1
732
+ yield ex
733
+
734
+
735
+ # ------------------------------------------------------------------------ HAR --
736
+
737
+ # a HAR holds decoded bodies, so the encodings that described the wire bytes no longer apply
738
+ _HAR_DROP = {"content-encoding", "transfer-encoding", "content-length"}
739
+
740
+
741
+ def _looks_like_har(path: str) -> bool:
742
+ if not path.lower().endswith(".json"):
743
+ return False
744
+ try:
745
+ with open(path, "rb") as f:
746
+ head = f.read(4096).lstrip()
747
+ except OSError:
748
+ return False
749
+ return head.startswith(b"{") and b'"log"' in head
750
+
751
+
752
+ def _har_headers(headers) -> list[tuple[str, str]]:
753
+ out = []
754
+ for h in headers or []:
755
+ name = str((h or {}).get("name", "")).strip()
756
+ if name and not name.startswith(":"): # HTTP/2 pseudo-headers
757
+ out.append((name, str((h or {}).get("value", ""))))
758
+ return out
759
+
760
+
761
+ def _har_item(entry: dict):
762
+ """One HAR entry as the <item> element the rest of the parser reads."""
763
+ from xml.etree.ElementTree import Element, SubElement
764
+ rq, rs = entry.get("request") or {}, entry.get("response") or {}
765
+ url = str(rq.get("url") or "")
766
+ u = urllib.parse.urlsplit(url)
767
+ host = (u.hostname or "").lower()
768
+ scheme = (u.scheme or "https").lower()
769
+ port = u.port or (443 if scheme == "https" else 80)
770
+ path = (u.path or "/") + ("?" + u.query if u.query else "")
771
+ status = int(rs.get("status") or 0)
772
+ if not host or not status:
773
+ return None # a request that never got a response
774
+ method = str(rq.get("method") or "GET").upper()
775
+ ver = str(rq.get("httpVersion") or "HTTP/1.1")
776
+ ver = ver if ver.upper().startswith("HTTP/") and "." in ver else "HTTP/1.1"
777
+
778
+ rq_headers = _har_headers(rq.get("headers"))
779
+ if not any(k.lower() == "host" for k, _ in rq_headers):
780
+ rq_headers.insert(0, ("Host", u.netloc.split("@")[-1] or host))
781
+ body = (rq.get("postData") or {})
782
+ body_text = body.get("text")
783
+ if body_text is None and body.get("params"):
784
+ body_text = urllib.parse.urlencode([(p.get("name", ""), p.get("value", "")) for p in body["params"]])
785
+ raw_rq = f"{method} {path} {ver}\r\n".encode("latin-1", "replace")
786
+ raw_rq += "".join(f"{k}: {v}\r\n" for k, v in rq_headers if k.lower() not in _HAR_DROP or k.lower() == "content-length").encode("latin-1", "replace")
787
+ raw_rq += b"\r\n" + (body_text or "").encode("utf-8", "replace")
788
+
789
+ content = rs.get("content") or {}
790
+ rs_headers = [(k, v) for k, v in _har_headers(rs.get("headers")) if k.lower() not in _HAR_DROP]
791
+ text = content.get("text")
792
+ if text is None:
793
+ rs_body = b""
794
+ elif content.get("encoding") == "base64":
795
+ try:
796
+ rs_body = base64.b64decode(text)
797
+ except Exception:
798
+ rs_body = b""
799
+ else:
800
+ rs_body = str(text).encode("utf-8", "replace")
801
+ raw_rs = f"HTTP/1.1 {status} {rs.get('statusText') or ''}\r\n".encode("latin-1", "replace")
802
+ raw_rs += "".join(f"{k}: {v}\r\n" for k, v in rs_headers).encode("latin-1", "replace") + b"\r\n" + rs_body
803
+
804
+ item = Element("item")
805
+ for tag, val in (("method", method), ("host", host), ("port", str(port)), ("protocol", scheme), ("path", path),
806
+ ("status", str(status)), ("mimetype", str(content.get("mimeType") or ""))):
807
+ SubElement(item, tag).text = val
808
+ SubElement(item, "request", {"base64": "true"}).text = base64.b64encode(raw_rq).decode()
809
+ SubElement(item, "response", {"base64": "true"}).text = base64.b64encode(raw_rs).decode()
810
+ return item
811
+
812
+
813
+ class _JsonStream:
814
+ """One JSON document read value by value, so only the value being read is ever in memory.
815
+
816
+ A HAR keeps every response body inline; `json.load` would hold the whole capture (and then its
817
+ parsed copy) at once. This walks the outer objects and hands out `log.entries` one entry at a time.
818
+ """
819
+
820
+ _WS = " \t\r\n"
821
+
822
+ def __init__(self, f, chunk: int = 1 << 20):
823
+ self.f, self.chunk = f, chunk
824
+ self.buf, self.pos, self.eof = "", 0, False
825
+ self.dec = json.JSONDecoder()
826
+
827
+ def _more(self, want: int = 0) -> bool:
828
+ if self.eof:
829
+ return False
830
+ if self.pos >= self.chunk: # drop what has been read
831
+ self.buf, self.pos = self.buf[self.pos:], 0
832
+ data = self.f.read(max(self.chunk, want)) # at least what is pending: a big value takes log(n) retries
833
+ if not data:
834
+ self.eof = True
835
+ return False
836
+ self.buf += data
837
+ return True
838
+
839
+ def peek(self) -> str:
840
+ while True:
841
+ while self.pos < len(self.buf) and self.buf[self.pos] in self._WS:
842
+ self.pos += 1
843
+ if self.pos < len(self.buf):
844
+ return self.buf[self.pos]
845
+ if not self._more():
846
+ return ""
847
+
848
+ def take(self, ch: str) -> None:
849
+ got = self.peek()
850
+ if got != ch:
851
+ raise ValueError(f"expected {ch!r}, found {got or 'the end of the file'!r}")
852
+ self.pos += 1
853
+
854
+ def value(self):
855
+ self.peek()
856
+ while True:
857
+ try:
858
+ v, end = self.dec.raw_decode(self.buf, self.pos)
859
+ except ValueError:
860
+ if self._more(len(self.buf) - self.pos):
861
+ continue
862
+ raise
863
+ if end == len(self.buf) and self._more(): # a number may go on in the next chunk
864
+ continue
865
+ self.pos = end
866
+ return v
867
+
868
+ def keys(self):
869
+ """The keys of an object; the caller reads each key's value before asking for the next."""
870
+ self.take("{")
871
+ if self.peek() == "}":
872
+ self.pos += 1
873
+ return
874
+ while True:
875
+ k = self.value()
876
+ if not isinstance(k, str):
877
+ raise ValueError("expected an object key")
878
+ self.take(":")
879
+ yield k
880
+ if self.peek() == ",":
881
+ self.pos += 1
882
+ continue
883
+ self.take("}")
884
+ return
885
+
886
+ def items(self):
887
+ self.take("[")
888
+ if self.peek() == "]":
889
+ self.pos += 1
890
+ return
891
+ while True:
892
+ yield self.value()
893
+ if self.peek() == ",":
894
+ self.pos += 1
895
+ continue
896
+ self.take("]")
897
+ return
898
+
899
+ def end(self) -> None:
900
+ if self.peek():
901
+ raise ValueError("extra data after the JSON document")
902
+
903
+
904
+ def _har_entries(f, chunk: int = 1 << 20):
905
+ """`log.entries` of a HAR, one at a time. The rest of the file is read past, and checked to be JSON."""
906
+ js = _JsonStream(f, chunk)
907
+ found = False
908
+ if js.peek() != "{":
909
+ js.value()
910
+ js.end()
911
+ raise ValueError("not a HAR file (no log.entries)")
912
+ for key in js.keys():
913
+ if key == "log" and not found and js.peek() == "{":
914
+ for k in js.keys():
915
+ if k == "entries" and not found and js.peek() == "[":
916
+ found = True
917
+ yield from js.items()
918
+ else:
919
+ js.value()
920
+ else:
921
+ js.value()
922
+ js.end()
923
+ if not found:
924
+ raise ValueError("not a HAR file (no log.entries)")
925
+
926
+
927
+ def _parse_har(path, role, stats):
928
+ """A HAR file (browser DevTools, Hetty, mitmproxy, ZAP, Charles ...), read as if it were a Burp export.
929
+
930
+ Streamed: entries are read and turned into exchanges one at a time, never the whole file at once.
931
+ """
932
+ source = os.path.basename(path)
933
+ with open(path, encoding="utf-8-sig", errors="replace") as f:
934
+ yield from _har_exchanges(_har_entries(f), source, role, stats)
935
+
936
+
937
+ def _har_exchanges(entries, source, role, stats):
938
+ idx = 1
939
+ item_no = -1
940
+ while True:
941
+ try:
942
+ entry = next(entries)
943
+ except StopIteration:
944
+ return
945
+ except ValueError as e:
946
+ raise ValueError(str(e) if str(e).startswith("not a HAR") else f"not a HAR file (not valid JSON: {e})")
947
+ item_no += 1
948
+ stats["items"] += 1
949
+ try:
950
+ item = _har_item(entry) if isinstance(entry, dict) else None
951
+ ex = _parse_item(item, idx, role) if item is not None else None
952
+ except Exception:
953
+ ex = None
954
+ if ex is None:
955
+ stats["skipped"] += 1
956
+ continue
957
+ ex.source, ex.item = source, item_no
958
+ idx += 1
959
+ stats["parsed"] += 1
960
+ yield ex
961
+
962
+
963
+ # ---------------------------------------------------------------------- Caido --
964
+
965
+ # Caido's HTTP history export: a JSON array of requests, each with its response nested under
966
+ # `response`, or a CSV with one row per pair and the response columns prefixed `response_`.
967
+ # `raw` holds the whole request or response, base64-encoded.
968
+ _CAIDO_CSV_NEEDED = ("host", "method", "raw", "response_raw")
969
+
970
+
971
+ def _looks_like_caido_json(path: str) -> bool:
972
+ if not path.lower().endswith(".json"):
973
+ return False
974
+ try:
975
+ with open(path, "rb") as f:
976
+ head = f.read(4096).lstrip(b"\xef\xbb\xbf \t\r\n")
977
+ except OSError:
978
+ return False
979
+ return head.startswith(b"[") and b'"raw"' in head and (b'"is_tls"' in head or b'"host"' in head)
980
+
981
+
982
+ def _caido_csv_header(path: str) -> list[str] | None:
983
+ import csv
984
+ try:
985
+ with open(path, newline="", encoding="utf-8-sig", errors="replace") as fh:
986
+ header = [h.strip().lower() for h in next(csv.reader(fh), [])]
987
+ except OSError:
988
+ return None
989
+ return header if all(c in header for c in _CAIDO_CSV_NEEDED) else None
990
+
991
+
992
+ def _truthy(v) -> bool:
993
+ return v is True or str(v).strip().lower() in ("true", "1", "yes")
994
+
995
+
996
+ def _caido_item(rec: dict):
997
+ """One Caido request (JSON object, or CSV row with `response_*` columns) as an <item> element."""
998
+ from xml.etree.ElementTree import Element, SubElement
999
+ rs = rec.get("response")
1000
+ if not isinstance(rs, dict):
1001
+ rs = {k[len("response_"):]: v for k, v in rec.items() if k.startswith("response_")}
1002
+ raw_rq, raw_rs = str(rec.get("raw") or "").strip(), str(rs.get("raw") or "").strip()
1003
+ host = str(rec.get("host") or "").strip().lower()
1004
+ status = _int(str(rs.get("status_code") or "").strip(), 0)
1005
+ if not host or not raw_rq or not raw_rs or not status:
1006
+ return None # a request that never got a response
1007
+ tls = _truthy(rec.get("is_tls"))
1008
+ path = str(rec.get("path") or "/").strip() or "/"
1009
+ query = str(rec.get("query") or "").strip().lstrip("?")
1010
+ if query and "?" not in path:
1011
+ path += "?" + query
1012
+ item = Element("item")
1013
+ for tag, val in (("method", str(rec.get("method") or "GET").upper()), ("host", host),
1014
+ ("port", str(rec.get("port") or (443 if tls else 80))),
1015
+ ("protocol", "https" if tls else "http"), ("path", path),
1016
+ ("status", str(status)), ("mimetype", "")):
1017
+ SubElement(item, tag).text = val
1018
+ for tag, val in (("request", raw_rq), ("response", raw_rs)):
1019
+ b64, text = _b64_text(val)
1020
+ SubElement(item, tag, {"base64": b64}).text = text
1021
+ return item
1022
+
1023
+
1024
+ def _caido_records(path: str, csv_header: list[str] | None):
1025
+ if csv_header is not None:
1026
+ import csv
1027
+ csv.field_size_limit(2 ** 31 - 1)
1028
+ with open(path, newline="", encoding="utf-8-sig", errors="replace") as fh:
1029
+ reader = csv.reader(fh)
1030
+ next(reader, None)
1031
+ for row in reader:
1032
+ if any(c.strip() for c in row):
1033
+ yield dict(zip(csv_header, row))
1034
+ return
1035
+ with open(path, encoding="utf-8-sig", errors="replace") as f:
1036
+ try:
1037
+ yield from _JsonStream(f).items()
1038
+ except ValueError as e:
1039
+ raise ValueError(f"not a Caido export (not a valid JSON array: {e})")
1040
+
1041
+
1042
+ def _parse_caido(path, role, stats, skip_tools, only_tools, csv_header=None):
1043
+ """A Caido HTTP history export (JSON or CSV), read as if it were a Burp export. Streamed.
1044
+
1045
+ `source` (intercept, replay, automate ...) plays the part of Logger++'s Tool column for
1046
+ `skip_tools` / `only_tools`.
1047
+ """
1048
+ source = os.path.basename(path)
1049
+ skip = {t.strip().lower() for t in skip_tools if t.strip()}
1050
+ only = {t.strip().lower() for t in only_tools if t.strip()}
1051
+ tools: dict[str, int] = stats.setdefault("tools", {})
1052
+ stats["format"] = "caido"
1053
+ idx = 1
1054
+ for item_no, rec in enumerate(_caido_records(path, csv_header)):
1055
+ if not isinstance(rec, dict):
1056
+ stats["items"] += 1
1057
+ stats["skipped"] += 1
1058
+ continue
1059
+ tool = str(rec.get("source") or "").strip() or "?"
1060
+ tools[tool] = tools.get(tool, 0) + 1
1061
+ if (skip and tool.lower() in skip) or (only and tool.lower() not in only):
1062
+ stats["filtered"] = stats.get("filtered", 0) + 1
1063
+ continue
1064
+ stats["items"] += 1
1065
+ try:
1066
+ item = _caido_item(rec)
1067
+ ex = _parse_item(item, idx, role) if item is not None else None
1068
+ except Exception:
1069
+ ex = None
1070
+ if ex is None:
1071
+ stats["skipped"] += 1
1072
+ continue
1073
+ ex.source, ex.item = source, item_no
1074
+ idx += 1
1075
+ stats["parsed"] += 1
1076
+ yield ex
1077
+
1078
+
1079
+ def parse_elements(items, source: str, role: str | None = None, stats: dict | None = None):
1080
+ """Like parse_items, for <item> elements already in memory (the crawler's output).
1081
+
1082
+ The crawler hands over what it fetched without ever writing a raw
1083
+ request/response to disk; redaction still happens here, before an Exchange
1084
+ leaves this module.
1085
+ """
1086
+ if stats is None:
1087
+ stats = {}
1088
+ for k in ("items", "parsed", "skipped"):
1089
+ stats.setdefault(k, 0)
1090
+ idx = 1 # evidence ids start at 1
1091
+ for item_no, elem in enumerate(items):
1092
+ stats["items"] += 1
1093
+ try:
1094
+ ex = _parse_item(elem, idx, role)
1095
+ except Exception:
1096
+ ex = None
1097
+ if ex is None:
1098
+ stats["skipped"] += 1
1099
+ continue
1100
+ ex.source, ex.item = source, item_no
1101
+ idx += 1
1102
+ stats["parsed"] += 1
1103
+ yield ex
1104
+
1105
+
1106
+ def _int(text: str | None, default=None):
1107
+ t = (text or "").strip()
1108
+ return int(t) if t.isdigit() else default
1109
+
1110
+
1111
+ def _parse_item(elem, idx: int, role: str | None) -> Exchange | None:
1112
+ method = (_text(elem.find("method")) or "GET").strip().upper()
1113
+ host = (_text(elem.find("host")) or "").strip().lower()
1114
+ port = _int(_text(elem.find("port")), 0)
1115
+ protocol = (_text(elem.find("protocol")) or "https").strip().lower()
1116
+ path = (_text(elem.find("path")) or "").strip()
1117
+ if not path:
1118
+ url = (_text(elem.find("url")) or "").strip()
1119
+ parts = urllib.parse.urlsplit(url) if url else None
1120
+ path = ((parts.path or "/") + ("?" + parts.query if parts.query else "")) if parts else "/"
1121
+ status = _int(_text(elem.find("status")))
1122
+ mime = (_text(elem.find("mimetype")) or "").strip()
1123
+ if not host or not re.match(r"^[a-z0-9.\-:\[\]]{1,255}$", host):
1124
+ return None
1125
+ if not re.match(r"^[A-Z]{1,20}$", method):
1126
+ return None
1127
+
1128
+ req_el = elem.find("request")
1129
+ resp_el = elem.find("response")
1130
+ req_raw = _b64(_text(req_el), req_el is not None and req_el.get("base64") == "true")
1131
+ resp_raw = _b64(_text(resp_el), resp_el is not None and resp_el.get("base64") == "true")
1132
+
1133
+ req_head, req_body_b = _split_http(req_raw) if req_raw else ("", b"")
1134
+ resp_head, resp_body_b = _split_http(resp_raw) if resp_raw else ("", b"")
1135
+ req_start, req_headers = _parse_headers(req_head)
1136
+ resp_start, resp_headers = _parse_headers(resp_head)
1137
+ if status is None and resp_start.startswith("HTTP/"):
1138
+ status = _int(resp_start.split(" ")[1] if " " in resp_start else None)
1139
+
1140
+ if "chunked" in _header(req_headers, "transfer-encoding").lower():
1141
+ req_body_b = _dechunk(req_body_b)
1142
+ if "chunked" in _header(resp_headers, "transfer-encoding").lower():
1143
+ resp_body_b = _dechunk(resp_body_b)
1144
+ req_body_b, _req_dec_ok = _decompress(req_body_b, _header(req_headers, "content-encoding"))
1145
+ resp_body_b, resp_dec_ok = _decompress(resp_body_b, _header(resp_headers, "content-encoding"))
1146
+ req_ct = _header(req_headers, "content-type")
1147
+ resp_ct = _header(resp_headers, "content-type")
1148
+ req_body_text = req_body_b.decode("utf-8", "replace")
1149
+
1150
+ # ---- metadata that must be read before values are masked (names only) ----
1151
+ cookie_names = _cookie_names(_header(req_headers, "cookie"))
1152
+ set_cookies = _set_cookies(resp_headers)
1153
+ raw_query = _parse_query(path)
1154
+ credentials = _credentials(req_headers, cookie_names, raw_query)
1155
+ referer_host, referer_path = _referer(req_headers)
1156
+ preflight = method == "OPTIONS" and bool(_header(req_headers, "access-control-request-method"))
1157
+
1158
+ secrets: list[SecretFingerprint] = []
1159
+ scrub = _scrubber(_exchange_secrets(req_headers, resp_headers))
1160
+ red_path, s0 = redact_path(path)
1161
+ red_path = scrub(red_path)
1162
+ ptemplate = route_template(red_path, template_path(red_path))
1163
+ tech = []
1164
+ for k, v in resp_headers:
1165
+ if k.lower() in _TECH_HEADERS and v:
1166
+ tech.append((k.lower(), v[:80]))
1167
+
1168
+ req_headers_r, s1 = redact_headers(req_headers)
1169
+ resp_headers_r, s2 = redact_headers(resp_headers)
1170
+ req_headers_r = [(k, scrub(v)) for k, v in req_headers_r]
1171
+ resp_headers_r = [(k, scrub(v)) for k, v in resp_headers_r]
1172
+ raw_body_params = _parse_body_params(req_body_text, req_ct)
1173
+ ptypes = param_types("query", raw_query) + param_types("body", raw_body_params)
1174
+ beacon = _bot_beacon(path, req_headers, req_body_text)
1175
+ query_r, s3 = redact_params(raw_query)
1176
+ body_params_r, s4 = redact_params(raw_body_params)
1177
+ query_r = [(k, scrub(v)) for k, v in query_r]
1178
+ body_params_r = [(k, scrub(v)) for k, v in body_params_r]
1179
+ req_body_r, s5 = redact_body(req_body_text[:BODY_KEEP])
1180
+ req_body_r = scrub(req_body_r)
1181
+ gql_ops = _gql_ops(ptemplate, req_body_r, req_ct)
1182
+ tech_r = []
1183
+ for k, v in tech:
1184
+ masked, s7 = redact_body(v)
1185
+ secrets.extend(s7)
1186
+ tech_r.append((k, scrub(masked)))
1187
+
1188
+ # the whole response body is scanned (secrets + references), up to a cap
1189
+ is_js = _is_js_response(mime, ptemplate, resp_ct)
1190
+ is_html = _is_html_response(mime, resp_ct)
1191
+ # an undecodable content-encoding (e.g. brotli without the library) means
1192
+ # the body could not be scanned — flag it so the model can say so
1193
+ scan_truncated = (is_js or is_html) and (not resp_dec_ok or elem.find("truncated") is not None)
1194
+ js_refs: set = set()
1195
+ js_assets: set = set()
1196
+ stack_text = ""
1197
+ if is_js or is_html:
1198
+ scan_b = resp_body_b
1199
+ if len(scan_b) > MAX_SCAN_BYTES:
1200
+ scan_b, scan_truncated = scan_b[:MAX_SCAN_BYTES], True
1201
+ resp_full_r, s6 = redact_body(scan_b.decode("utf-8", "replace"))
1202
+ resp_full_r = scrub(resp_full_r)
1203
+ js_refs = extract_js_refs(resp_full_r) if is_js else extract_html_refs(resp_full_r)
1204
+ if is_js:
1205
+ hdr_map = next((v for k, v in resp_headers if k.lower() in ("sourcemap", "x-sourcemap")), None)
1206
+ js_assets = extract_script_assets(resp_full_r, host, ptemplate, hdr_map)
1207
+ resp_body_r = resp_full_r[:CODE_KEEP if is_js else BODY_KEEP]
1208
+ stack_text = resp_full_r
1209
+ else:
1210
+ resp_body_r, s6 = redact_body(resp_body_b[:BODY_KEEP * 5].decode("utf-8", "replace"))
1211
+ resp_body_r = scrub(resp_body_r)[:BODY_KEEP]
1212
+ resp_fields = [] if is_js or is_html else _resp_fields(resp_body_b, resp_ct)
1213
+
1214
+ stack = detect_stack({k.lower(): v for k, v in resp_headers},
1215
+ [c["name"] for c in set_cookies] + cookie_names, stack_text, red_path, is_js or is_html)
1216
+
1217
+ for chunk in (s0, s1, s2, s3, s4, s5, s6):
1218
+ secrets.extend(chunk)
1219
+
1220
+ # a fully redacted view of the exchange for the report's HTTP panes.
1221
+ # The request line is rebuilt with the redacted path; every header value
1222
+ # and both bodies are already masked. No raw value is present.
1223
+ _ver = req_start.split(" ")[-1] if req_start.count(" ") >= 2 else "HTTP/1.1"
1224
+ _EV_BODY = CODE_KEEP if is_js else BODY_KEEP if is_html else 6000 # scripts and pages show in full in the report
1225
+ _hdr = lambda hs: [[k, redact_named(v)] for k, v in hs] # scrub URLs in header values
1226
+ ev = {
1227
+ "request": {
1228
+ "line": redact_named(f"{method} {red_path} {_ver}".strip()),
1229
+ "headers": _hdr(req_headers_r),
1230
+ "body": redact_named(req_body_r[:6000]),
1231
+ "truncated": len(req_body_r) > 6000,
1232
+ },
1233
+ "response": {
1234
+ "line": (resp_start or (f"HTTP/1.1 {status}" if status is not None else "")).strip(),
1235
+ "headers": _hdr(resp_headers_r),
1236
+ "body": redact_named(resp_body_r[:_EV_BODY]),
1237
+ "truncated": len(resp_body_r) > _EV_BODY or scan_truncated,
1238
+ },
1239
+ }
1240
+
1241
+ return Exchange(
1242
+ index=idx,
1243
+ method=method,
1244
+ scheme=protocol,
1245
+ host=host,
1246
+ port=port,
1247
+ path=red_path,
1248
+ path_template=ptemplate,
1249
+ status=status,
1250
+ mime=mime,
1251
+ req_headers=req_headers_r,
1252
+ resp_headers=resp_headers_r,
1253
+ query_params=query_r,
1254
+ body_params=body_params_r,
1255
+ req_body=req_body_r,
1256
+ resp_body=resp_body_r,
1257
+ role=role,
1258
+ secrets=secrets,
1259
+ referer_host=referer_host,
1260
+ referer_path=referer_path,
1261
+ cookie_names=cookie_names,
1262
+ set_cookies=set_cookies,
1263
+ credentials=credentials,
1264
+ tech=tech_r,
1265
+ js_refs=sorted(js_refs, key=lambda r: (r[2], r[0] or "", r[1] or "", r[3])),
1266
+ js_assets=sorted(js_assets),
1267
+ stack=stack,
1268
+ scan_truncated=scan_truncated,
1269
+ gql_ops=gql_ops,
1270
+ preflight=preflight,
1271
+ param_types=ptypes,
1272
+ resp_fields=resp_fields,
1273
+ beacon=beacon,
1274
+ ev_z=ev_pack(ev),
1275
+ )