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.
- proxy2model/__init__.py +19 -0
- proxy2model/__main__.py +6 -0
- proxy2model/atomic.py +68 -0
- proxy2model/cli/__init__.py +3 -0
- proxy2model/cli/analyze.py +501 -0
- proxy2model/cli/app.py +408 -0
- proxy2model/cli/browse.py +279 -0
- proxy2model/cli/build.py +399 -0
- proxy2model/cli/common.py +190 -0
- proxy2model/cli/history.py +224 -0
- proxy2model/cli/mcp.py +184 -0
- proxy2model/cli/ui.py +62 -0
- proxy2model/core/__init__.py +1 -0
- proxy2model/core/changes.py +124 -0
- proxy2model/core/context.py +571 -0
- proxy2model/core/diff.py +330 -0
- proxy2model/core/domains.py +98 -0
- proxy2model/core/graph.py +562 -0
- proxy2model/core/methodology.py +343 -0
- proxy2model/core/model.py +998 -0
- proxy2model/core/stack.py +218 -0
- proxy2model/data/fonts/LICENSES.md +16 -0
- proxy2model/data/fonts/inter-latin-wght-normal.woff2 +0 -0
- proxy2model/data/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
- proxy2model/data/fonts/nippo-200.woff2 +0 -0
- proxy2model/data/fonts/nippo-300.woff2 +0 -0
- proxy2model/data/fonts/nippo-400.woff2 +0 -0
- proxy2model/data/fonts/nippo-500.woff2 +0 -0
- proxy2model/data/fonts/nippo-700.woff2 +0 -0
- proxy2model/data/public_suffix_list.dat +16497 -0
- proxy2model/db/__init__.py +1 -0
- proxy2model/db/bql.py +710 -0
- proxy2model/db/bql_js.py +278 -0
- proxy2model/db/store.py +547 -0
- proxy2model/export/__init__.py +1 -0
- proxy2model/export/openapi.py +248 -0
- proxy2model/ingest/__init__.py +1 -0
- proxy2model/ingest/browse.py +181 -0
- proxy2model/ingest/browser.py +1058 -0
- proxy2model/ingest/crawl.py +673 -0
- proxy2model/ingest/extract.py +208 -0
- proxy2model/ingest/infer.py +75 -0
- proxy2model/ingest/journey.py +148 -0
- proxy2model/ingest/live.py +96 -0
- proxy2model/ingest/parse.py +1275 -0
- proxy2model/ingest/redact.py +535 -0
- proxy2model/py.typed +0 -0
- proxy2model/recon/__init__.py +1 -0
- proxy2model/recon/osint.py +607 -0
- proxy2model/report/__init__.py +1 -0
- proxy2model/report/animate.py +163 -0
- proxy2model/report/dash.py +469 -0
- proxy2model/report/fonts.py +51 -0
- proxy2model/report/html.py +988 -0
- proxy2model/report/inventory.py +258 -0
- proxy2model/report/map.py +515 -0
- proxy2model/report/pages.py +537 -0
- proxy2model/report/query.py +306 -0
- proxy2model/report/shot.py +55 -0
- proxy2model/report/view.py +213 -0
- proxy2model-2.0.0.dist-info/METADATA +148 -0
- proxy2model-2.0.0.dist-info/RECORD +66 -0
- proxy2model-2.0.0.dist-info/WHEEL +5 -0
- proxy2model-2.0.0.dist-info/entry_points.txt +2 -0
- proxy2model-2.0.0.dist-info/licenses/LICENSE +201 -0
- 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
|
+
)
|