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,998 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Build a web-app model from redacted exchanges.
|
|
3
|
+
|
|
4
|
+
The model is a six-layer graph. Every node cites the exchange indices it came
|
|
5
|
+
from (its evidence). Every edge is OBSERVED (seen in traffic) or INFERRED
|
|
6
|
+
(deduced from static references). Nothing here is called a vulnerability.
|
|
7
|
+
|
|
8
|
+
Layers:
|
|
9
|
+
1 edge first-party hosts: schemes, ports, tech markers
|
|
10
|
+
2 routes pages / navigations
|
|
11
|
+
3 code scripts (JS)
|
|
12
|
+
4 apis endpoints, parameters, GraphQL operations, with api_state
|
|
13
|
+
5 trust third parties, roles, auth schemes, cookies (names + flags only)
|
|
14
|
+
6 unknowns named blind spots
|
|
15
|
+
|
|
16
|
+
Scope: the app is not one host. By default every host under the registrable
|
|
17
|
+
domain of the busiest host (per the Public Suffix List, see domains.py) is
|
|
18
|
+
first-party: www., api., cdn. of the same site. Pass `scope` to set it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
from collections import Counter, defaultdict
|
|
26
|
+
from dataclasses import asdict, dataclass, field
|
|
27
|
+
|
|
28
|
+
from .domains import registrable_domain # noqa: F401 (re-exported)
|
|
29
|
+
from ..ingest.extract import VAR_BASE
|
|
30
|
+
from ..ingest.parse import Exchange, ev_pack, ev_unpack
|
|
31
|
+
|
|
32
|
+
API_HINT = re.compile(r"/(api|v\d+|graphql|rest|gql)(/|$)", re.I)
|
|
33
|
+
PRIV_RE = re.compile(
|
|
34
|
+
r"/(admin|administrator|internal|manage|management|config|privileged|superuser|"
|
|
35
|
+
r"staff|root|debug|actuator|console|backoffice|ops)(/|$)", re.I)
|
|
36
|
+
# Beacons and analytics calls: real traffic, but not where anyone looks for a weakness, so they never outrank the app's own endpoints.
|
|
37
|
+
TELEMETRY_RE = re.compile(
|
|
38
|
+
r"/(collect|track|tracking|trackevents?|beacon|pixel|analytics|telemetry|gtag|gtm|ping|rum|csp-report)(/|$)", re.I)
|
|
39
|
+
STATIC_EXT_RE = re.compile(
|
|
40
|
+
r"\.(?:png|jpe?g|gif|svg|ico|webp|avif|bmp|css|woff2?|ttf|otf|eot|map|mp4|webm|"
|
|
41
|
+
r"mp3|wav|pdf|zip)$", re.I)
|
|
42
|
+
# a web app manifest is JSON, but a static file the browser reads, not an API
|
|
43
|
+
MANIFEST_RE = re.compile(r"(?:^|/)(?:manifest\.json|[^/]*\.webmanifest)$", re.I)
|
|
44
|
+
_PLACEHOLDER_RE = re.compile(r"\{[^/{}]*\}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Node:
|
|
49
|
+
id: str
|
|
50
|
+
type: str
|
|
51
|
+
layer: int
|
|
52
|
+
label: str
|
|
53
|
+
attrs: dict = field(default_factory=dict)
|
|
54
|
+
evidence: list[int] = field(default_factory=list)
|
|
55
|
+
roles: list[str] = field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Edge:
|
|
60
|
+
src: str
|
|
61
|
+
dst: str
|
|
62
|
+
type: str
|
|
63
|
+
state: str # OBSERVED | INFERRED
|
|
64
|
+
evidence: list[int] = field(default_factory=list)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class Unknown:
|
|
69
|
+
type: str
|
|
70
|
+
entity: str
|
|
71
|
+
we_know: list[str]
|
|
72
|
+
we_dont_know: str
|
|
73
|
+
next_step: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class EvidenceLog:
|
|
77
|
+
"""The request log behind every citation. Each entry keeps its small fields as a dict and its request and
|
|
78
|
+
response panes compressed; reading an entry gives the same full dict as before."""
|
|
79
|
+
|
|
80
|
+
def __init__(self) -> None:
|
|
81
|
+
self._rows: list[tuple[dict, bytes]] = []
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_entries(cls, entries) -> "EvidenceLog":
|
|
85
|
+
log = cls()
|
|
86
|
+
for e in entries:
|
|
87
|
+
meta = {k: v for k, v in e.items() if k not in ("request", "response")}
|
|
88
|
+
has = "request" in e or "response" in e
|
|
89
|
+
log._rows.append((meta, ev_pack({"request": e.get("request"), "response": e.get("response")}) if has else b""))
|
|
90
|
+
return log
|
|
91
|
+
|
|
92
|
+
def append(self, entry: dict) -> None:
|
|
93
|
+
self.extend([entry])
|
|
94
|
+
|
|
95
|
+
def extend(self, entries) -> None:
|
|
96
|
+
self._rows.extend(EvidenceLog.from_entries(entries)._rows)
|
|
97
|
+
|
|
98
|
+
def append_packed(self, meta: dict, z: bytes) -> None:
|
|
99
|
+
self._rows.append((meta, z))
|
|
100
|
+
|
|
101
|
+
def _full(self, row: tuple[dict, bytes]) -> dict:
|
|
102
|
+
meta, z = row
|
|
103
|
+
if not z:
|
|
104
|
+
return dict(meta)
|
|
105
|
+
d = ev_unpack(z)
|
|
106
|
+
return {**meta, "request": d.get("request"), "response": d.get("response")}
|
|
107
|
+
|
|
108
|
+
def __len__(self) -> int:
|
|
109
|
+
return len(self._rows)
|
|
110
|
+
|
|
111
|
+
def __iter__(self):
|
|
112
|
+
for row in self._rows:
|
|
113
|
+
yield self._full(row)
|
|
114
|
+
|
|
115
|
+
def __getitem__(self, i):
|
|
116
|
+
if isinstance(i, slice):
|
|
117
|
+
return [self._full(r) for r in self._rows[i]]
|
|
118
|
+
return self._full(self._rows[i])
|
|
119
|
+
|
|
120
|
+
def __bool__(self) -> bool:
|
|
121
|
+
return bool(self._rows)
|
|
122
|
+
|
|
123
|
+
def meta(self):
|
|
124
|
+
"""Each entry's small fields (id, method, host, path, status, role ...) without unpacking the panes."""
|
|
125
|
+
return (row[0] for row in self._rows)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class Model:
|
|
129
|
+
def __init__(self, name: str):
|
|
130
|
+
self.name = name
|
|
131
|
+
self.nodes: dict[str, Node] = {}
|
|
132
|
+
self.edges: list[Edge] = []
|
|
133
|
+
self.unknowns: list[Unknown] = []
|
|
134
|
+
self.roles: set[str] = set()
|
|
135
|
+
self.secrets: list[dict] = []
|
|
136
|
+
self.scope: list[str] = []
|
|
137
|
+
self.stats: dict = {}
|
|
138
|
+
self.stack: list[dict] = []
|
|
139
|
+
self.evidence_log = EvidenceLog()
|
|
140
|
+
self._edge_index: dict[tuple, Edge] = {}
|
|
141
|
+
self._secret_index: dict[tuple, dict] = {}
|
|
142
|
+
|
|
143
|
+
# ---- graph helpers ----
|
|
144
|
+
def add_node(self, id, type, layer, label, **attrs) -> Node:
|
|
145
|
+
n = self.nodes.get(id)
|
|
146
|
+
if n is None:
|
|
147
|
+
n = Node(id=id, type=type, layer=layer, label=label, attrs=dict(attrs))
|
|
148
|
+
self.nodes[id] = n
|
|
149
|
+
return n
|
|
150
|
+
|
|
151
|
+
def evidence(self, node_id: str, idx: int) -> None:
|
|
152
|
+
n = self.nodes.get(node_id)
|
|
153
|
+
if n and idx not in n.evidence:
|
|
154
|
+
n.evidence.append(idx)
|
|
155
|
+
|
|
156
|
+
def add_edge(self, src, dst, type, state, idx=None) -> None:
|
|
157
|
+
key = (src, dst, type)
|
|
158
|
+
e = self._edge_index.get(key)
|
|
159
|
+
if e is not None:
|
|
160
|
+
if idx is not None and idx not in e.evidence:
|
|
161
|
+
e.evidence.append(idx)
|
|
162
|
+
return
|
|
163
|
+
e = Edge(src, dst, type, state, [idx] if idx is not None else [])
|
|
164
|
+
self._edge_index[key] = e
|
|
165
|
+
self.edges.append(e)
|
|
166
|
+
|
|
167
|
+
def edges_of(self, node_id: str):
|
|
168
|
+
return [e for e in self.edges if e.src == node_id or e.dst == node_id]
|
|
169
|
+
|
|
170
|
+
# ---- counts ----
|
|
171
|
+
def counts(self) -> dict:
|
|
172
|
+
by_type: dict[str, int] = defaultdict(int)
|
|
173
|
+
for n in self.nodes.values():
|
|
174
|
+
by_type[n.type] += 1
|
|
175
|
+
states: dict[str, int] = defaultdict(int)
|
|
176
|
+
for n in self.nodes.values():
|
|
177
|
+
if n.type == "endpoint":
|
|
178
|
+
states[n.attrs.get("api_state", "UNKNOWN")] += 1
|
|
179
|
+
return {
|
|
180
|
+
"nodes": len(self.nodes),
|
|
181
|
+
"edges": len(self.edges),
|
|
182
|
+
"hosts": by_type.get("host", 0),
|
|
183
|
+
"routes": by_type.get("route", 0),
|
|
184
|
+
"scripts": by_type.get("script", 0),
|
|
185
|
+
"endpoints": by_type.get("endpoint", 0),
|
|
186
|
+
"parameters": by_type.get("parameter", 0),
|
|
187
|
+
"operations": by_type.get("operation", 0),
|
|
188
|
+
"third_parties": by_type.get("third_party", 0),
|
|
189
|
+
"auth": by_type.get("auth", 0),
|
|
190
|
+
"cookies": by_type.get("cookie", 0),
|
|
191
|
+
"tech": by_type.get("tech", 0),
|
|
192
|
+
"roles": len(self.roles),
|
|
193
|
+
"unknowns": len(self.unknowns),
|
|
194
|
+
"api_state": dict(states),
|
|
195
|
+
"secrets": len(self.secrets),
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------------ scope -----
|
|
200
|
+
|
|
201
|
+
def _bare_host(host: str) -> str:
|
|
202
|
+
"""Lower-case host with any port stripped; IPv6 literals keep their form."""
|
|
203
|
+
h = host.lower().strip()
|
|
204
|
+
if h.startswith("["): # [::1] or [::1]:8080
|
|
205
|
+
return h[1:h.index("]")] if "]" in h else h[1:]
|
|
206
|
+
if h.count(":") > 1: # bare IPv6, no brackets
|
|
207
|
+
return h
|
|
208
|
+
return h.split(":")[0]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def in_scope(host: str, scope: list[str]) -> bool:
|
|
212
|
+
h = _bare_host(host)
|
|
213
|
+
for s in scope:
|
|
214
|
+
s = _bare_host(s).lstrip("*").lstrip(".")
|
|
215
|
+
if h == s or h.endswith("." + s):
|
|
216
|
+
return True
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def match_key(path: str) -> str:
|
|
221
|
+
"""Placeholder-insensitive key: `/users/{id}` and `/users/{param}` match."""
|
|
222
|
+
key = _PLACEHOLDER_RE.sub("{}", path)
|
|
223
|
+
return key.rstrip("/") or "/"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# --------------------------------------------------------------- classify -----
|
|
227
|
+
|
|
228
|
+
def _is_js(ex: Exchange) -> bool:
|
|
229
|
+
m = ex.mime.lower()
|
|
230
|
+
return "script" in m or "ecmascript" in m or ex.path_template.endswith((".js", ".mjs"))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _is_html(ex: Exchange) -> bool:
|
|
234
|
+
return "html" in ex.mime.lower()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _is_static_asset(ex: Exchange) -> bool:
|
|
238
|
+
m = ex.mime.lower()
|
|
239
|
+
return (bool(STATIC_EXT_RE.search(ex.path_template)) or bool(MANIFEST_RE.search(ex.path_template))
|
|
240
|
+
or m in ("css", "image", "gif", "jpeg", "png", "svg", "font", "woff", "woff2", "ico"))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _is_api(ex: Exchange) -> bool:
|
|
244
|
+
if API_HINT.search(ex.path_template):
|
|
245
|
+
return True
|
|
246
|
+
m = ex.mime.lower()
|
|
247
|
+
return m in ("json", "xml") or "json" in m
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def classify(ex: Exchange) -> str:
|
|
251
|
+
"""script | asset | preflight | route | endpoint for a first-party exchange."""
|
|
252
|
+
if ex.preflight:
|
|
253
|
+
return "preflight"
|
|
254
|
+
if ex.status == 101:
|
|
255
|
+
return "endpoint" # a WebSocket (or other protocol switch) handshake
|
|
256
|
+
if _is_js(ex):
|
|
257
|
+
return "script"
|
|
258
|
+
if _is_static_asset(ex) and ex.method in ("GET", "HEAD"):
|
|
259
|
+
return "asset"
|
|
260
|
+
if ex.method not in ("GET", "HEAD"):
|
|
261
|
+
return "endpoint" # form posts, redirects after POST, actions
|
|
262
|
+
if _is_html(ex):
|
|
263
|
+
return "route"
|
|
264
|
+
if _is_api(ex) or ex.query_params and not _is_html(ex) and not _is_redirect(ex):
|
|
265
|
+
return "endpoint"
|
|
266
|
+
return "route" # HTML pages, GET redirects, other GET resources
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _is_redirect(ex: Exchange) -> bool:
|
|
270
|
+
return ex.status is not None and 300 <= ex.status < 400
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ------------------------------------------------------------------ build -----
|
|
274
|
+
|
|
275
|
+
# Traversal, null-byte and overlong-UTF-8 paths are a scanner's payloads, not the app's routes.
|
|
276
|
+
_PROBE_PATH = re.compile(
|
|
277
|
+
r"(?i)(?:\.\.[/\\]|\.\.%2f|\.\.%5c|%2e%2e|%252e|%00|%c0%ae|%c0%af|%0d%0a|%0a[a-z0-9]|\\x00"
|
|
278
|
+
r"|%2a%7e1|\*~1|~1[/\\]" # IIS short-name probing
|
|
279
|
+
r"|%25%7b|%24%7b|\$\{|%23_?member|ognl|jndi:" # expression / log4shell injection
|
|
280
|
+
r"|%3cscript|<script|union(?:%20|\+|\s)select" # XSS / SQLi in the path
|
|
281
|
+
r"|\.(?:js|css|png|jpe?g|svg|ico|woff2?|map)/)") # a file used as a directory
|
|
282
|
+
_PROBE_METHODS = ("TRACE", "DEBUG", "TRACK")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def is_probe(ex: Exchange) -> bool:
|
|
286
|
+
return ex.method in _PROBE_METHODS or bool(_PROBE_PATH.search(ex.path or ""))
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
_FILE_SEG = re.compile(r"\.[A-Za-z0-9]{1,6}$")
|
|
290
|
+
SIBLING_STEM = 8 # this many siblings sharing a stem (`best-laundry-in-`): the stem is a pattern
|
|
291
|
+
SIBLING_SHAPE = 12 # this many siblings with the same children: the segment is a variable
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def collapse_siblings(exchanges: list[Exchange]) -> int:
|
|
295
|
+
"""Fold human-readable slugs into placeholders: `/live-channel/aaj-tak`, `/live-channel/dangal` ...
|
|
296
|
+
|
|
297
|
+
Id-shaped segments are already `{id}`; a slug (`best-laundry-in-leh`) is not shaped like one,
|
|
298
|
+
so a site with a page per city, channel or product would otherwise get a route per page. Two
|
|
299
|
+
signals say a segment is a variable and not a name:
|
|
300
|
+
|
|
301
|
+
* stem - 8+ siblings share a stem (`best-laundry-in-`): they become `best-laundry-in-{slug}`;
|
|
302
|
+
* shape - 12+ siblings each have the same children (`/channels/*/{id}`): they become `{slug}`.
|
|
303
|
+
|
|
304
|
+
Ten different endpoint names under `/api/` have neither, and are left alone. Files
|
|
305
|
+
(`logo.png`) and anything already templated are untouched. Rewrites `path_template` and the
|
|
306
|
+
referer path the same way, and returns how many templates changed.
|
|
307
|
+
"""
|
|
308
|
+
seen: dict[tuple, set] = {}
|
|
309
|
+
for ex in exchanges:
|
|
310
|
+
if ex.method == "OPTIONS" or is_probe(ex) or _FILE_SEG.search(ex.path_template.rsplit("/", 1)[-1] or ""):
|
|
311
|
+
continue
|
|
312
|
+
seen.setdefault((ex.host, ex.path_template.partition("?")[0]), set())
|
|
313
|
+
templates = {k: [x for x in k[1].strip("/").split("/") if x] for k in seen}
|
|
314
|
+
depth = max((len(v) for v in templates.values()), default=0)
|
|
315
|
+
cur = {k: list(v) for k, v in templates.items()}
|
|
316
|
+
for d in range(depth):
|
|
317
|
+
groups: dict[tuple, set] = {}
|
|
318
|
+
# the paths in each group: a group's prefix (segments before d) is fixed while depth d is
|
|
319
|
+
# folded, so looking a value's paths up here is the same as scanning every path, without the n^2
|
|
320
|
+
paths_in: dict[tuple, list] = {}
|
|
321
|
+
for k, segs in cur.items():
|
|
322
|
+
if len(segs) > d:
|
|
323
|
+
gk = (k[0], tuple(segs[:d]))
|
|
324
|
+
groups.setdefault(gk, set()).add(segs[d])
|
|
325
|
+
paths_in.setdefault(gk, []).append(segs)
|
|
326
|
+
for gk, values in groups.items():
|
|
327
|
+
in_group = paths_in[gk]
|
|
328
|
+
plain = {v for v in values if "{" not in v and not _FILE_SEG.search(v)}
|
|
329
|
+
if len(plain) < SIBLING_STEM:
|
|
330
|
+
continue
|
|
331
|
+
fold: dict[str, str] = {}
|
|
332
|
+
stems: dict[str, list] = {}
|
|
333
|
+
for v in plain:
|
|
334
|
+
if "-" in v:
|
|
335
|
+
stems.setdefault(v.rsplit("-", 1)[0] + "-", []).append(v)
|
|
336
|
+
for st, members in stems.items():
|
|
337
|
+
if len(members) >= SIBLING_STEM and len(st) >= 6:
|
|
338
|
+
for v in members:
|
|
339
|
+
fold[v] = st + "{slug}"
|
|
340
|
+
rest = plain - set(fold)
|
|
341
|
+
if len(rest) >= SIBLING_SHAPE:
|
|
342
|
+
kids_of: dict[str, set] = {}
|
|
343
|
+
for segs in in_group:
|
|
344
|
+
if len(segs) > d + 1 and segs[d] in rest:
|
|
345
|
+
kids_of.setdefault(segs[d], set()).add(segs[d + 1])
|
|
346
|
+
shaped = {v: frozenset(kids_of[v]) for v in rest if v in kids_of}
|
|
347
|
+
if shaped:
|
|
348
|
+
# the most common set of children (a tie cannot pass the 80% test below either way)
|
|
349
|
+
modal = Counter(shaped.values()).most_common(1)[0][0]
|
|
350
|
+
same = [v for v, ks in shaped.items() if ks == modal]
|
|
351
|
+
if len(same) >= SIBLING_SHAPE and len(same) >= 0.8 * len(shaped):
|
|
352
|
+
for v in same:
|
|
353
|
+
fold[v] = "{slug}"
|
|
354
|
+
if not fold:
|
|
355
|
+
continue
|
|
356
|
+
for segs in in_group:
|
|
357
|
+
if segs[d] in fold:
|
|
358
|
+
segs[d] = fold[segs[d]]
|
|
359
|
+
mapping = {}
|
|
360
|
+
for k, segs in cur.items():
|
|
361
|
+
new = "/" + "/".join(segs) if segs else "/"
|
|
362
|
+
if templates[k] and new != "/" + "/".join(templates[k]):
|
|
363
|
+
mapping[k] = new
|
|
364
|
+
if not mapping:
|
|
365
|
+
return 0
|
|
366
|
+
|
|
367
|
+
def rewrite(host, template):
|
|
368
|
+
base, q, query = template.partition("?")
|
|
369
|
+
new = mapping.get((host, base))
|
|
370
|
+
if new is None:
|
|
371
|
+
return template
|
|
372
|
+
trail = "/" if base.endswith("/") and len(base) > 1 else ""
|
|
373
|
+
return new + trail + (q + query if q else "")
|
|
374
|
+
|
|
375
|
+
for ex in exchanges:
|
|
376
|
+
ex.path_template = rewrite(ex.host, ex.path_template)
|
|
377
|
+
if ex.referer_host and ex.referer_path:
|
|
378
|
+
ex.referer_path = rewrite(ex.referer_host, ex.referer_path)
|
|
379
|
+
return len(mapping)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _param_of(pid: str, dead: set) -> bool:
|
|
383
|
+
"""Whether `param:<node id>:<name>` belongs to a dropped node. Node ids hold ':' too, so each one is
|
|
384
|
+
tried as the end of the id: a lookup per ':' instead of a scan of every dropped id."""
|
|
385
|
+
c = pid.find(":", 6)
|
|
386
|
+
while c > 0:
|
|
387
|
+
if pid[6:c] in dead:
|
|
388
|
+
return True
|
|
389
|
+
c = pid.find(":", c + 1)
|
|
390
|
+
return False
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def build(exchanges: list[Exchange], name: str, seed_host: str | None = None,
|
|
394
|
+
scope: list[str] | None = None, stats: dict | None = None) -> Model:
|
|
395
|
+
m = Model(name)
|
|
396
|
+
m.stats = dict(stats or {})
|
|
397
|
+
m.stats.setdefault("exchanges", len(exchanges))
|
|
398
|
+
for k in ("static_assets", "preflight", "third_party_requests"):
|
|
399
|
+
m.stats[k] = 0
|
|
400
|
+
# guard for library callers who chain parse_items() outputs: evidence ids
|
|
401
|
+
# must be unique, or every citation is ambiguous
|
|
402
|
+
if len({ex.index for ex in exchanges}) != len(exchanges):
|
|
403
|
+
for n, ex in enumerate(exchanges):
|
|
404
|
+
ex.index = n + 1
|
|
405
|
+
m.stats["slug_templates_folded"] = collapse_siblings(exchanges)
|
|
406
|
+
if seed_host is not None and not any(ex.host == seed_host and not _is_redirect(ex) for ex in exchanges):
|
|
407
|
+
seed_host = None # the given host only redirected elsewhere (example.com -> www.example.com)
|
|
408
|
+
if seed_host is None and exchanges:
|
|
409
|
+
hosts: dict[str, int] = defaultdict(int)
|
|
410
|
+
for ex in exchanges:
|
|
411
|
+
hosts[ex.host] += 1
|
|
412
|
+
# the busiest host — within the given scope if there is one, so a noisy
|
|
413
|
+
# CDN or analytics host can never become the app's primary host
|
|
414
|
+
pool = ([h for h in hosts if in_scope(h, scope)] if scope else []) or list(hosts)
|
|
415
|
+
seed_host = max(sorted(pool), key=lambda h: hosts[h])
|
|
416
|
+
m.scope = list(scope) if scope else ([registrable_domain(seed_host)] if seed_host else [])
|
|
417
|
+
primary = seed_host
|
|
418
|
+
|
|
419
|
+
runtime_seen: set[str] = set()
|
|
420
|
+
referenced: set[str] = set()
|
|
421
|
+
route_of_page: dict[tuple[str, str], str] = {} # (host, path) -> route id
|
|
422
|
+
|
|
423
|
+
# ---- pass 1: observed traffic ----
|
|
424
|
+
for ex in exchanges:
|
|
425
|
+
i = ex.index
|
|
426
|
+
role = ex.role
|
|
427
|
+
ev_entry = {
|
|
428
|
+
"id": i, "source": ex.source, "item": ex.item, "method": ex.method,
|
|
429
|
+
"host": ex.host, "path": ex.path_template, "status": ex.status,
|
|
430
|
+
"mime": ex.mime, "role": role,
|
|
431
|
+
}
|
|
432
|
+
m.evidence_log.append_packed(ev_entry, ex.ev_z)
|
|
433
|
+
for s in ex.secrets:
|
|
434
|
+
_add_secret(m, s.as_dict(), i)
|
|
435
|
+
if is_probe(ex): # a scanner's payload: evidence yes, a node in the app's map no
|
|
436
|
+
m.stats["probe_requests"] = m.stats.get("probe_requests", 0) + 1
|
|
437
|
+
continue
|
|
438
|
+
if ex.beacon: # a bot-detection vendor's sensor post: evidence yes, an API no
|
|
439
|
+
m.stats["bot_detection_requests"] = m.stats.get("bot_detection_requests", 0) + 1
|
|
440
|
+
continue
|
|
441
|
+
if role:
|
|
442
|
+
m.roles.add(role)
|
|
443
|
+
m.add_node(f"role:{role}", "role", 5, f"role: {role}")
|
|
444
|
+
m.evidence(f"role:{role}", i)
|
|
445
|
+
|
|
446
|
+
first_party = in_scope(ex.host, m.scope)
|
|
447
|
+
if not first_party:
|
|
448
|
+
tp_id = f"host:{ex.host}"
|
|
449
|
+
tp = m.add_node(tp_id, "third_party", 5, ex.host, scope="external", requests=0)
|
|
450
|
+
tp.attrs["requests"] = tp.attrs.get("requests", 0) + 1
|
|
451
|
+
m.evidence(tp_id, i)
|
|
452
|
+
m.stats["third_party_requests"] += 1
|
|
453
|
+
if _is_js(ex):
|
|
454
|
+
sid = f"script:{ex.host}{ex.path_template}"
|
|
455
|
+
m.add_node(sid, "script", 3, ex.path_template.split("/")[-1] or "script.js",
|
|
456
|
+
host=ex.host, scope="external")
|
|
457
|
+
m.evidence(sid, i)
|
|
458
|
+
m.add_edge(tp_id, sid, "LOADS", "OBSERVED", i)
|
|
459
|
+
continue
|
|
460
|
+
|
|
461
|
+
host_id = f"host:{ex.host}"
|
|
462
|
+
h = m.add_node(host_id, "host", 1, ex.host, scope="first-party",
|
|
463
|
+
schemes=[], ports=[], tech={})
|
|
464
|
+
m.evidence(host_id, i)
|
|
465
|
+
if ex.scheme and ex.scheme not in h.attrs["schemes"]:
|
|
466
|
+
h.attrs["schemes"].append(ex.scheme)
|
|
467
|
+
if ex.port and ex.port not in h.attrs["ports"]:
|
|
468
|
+
h.attrs["ports"].append(ex.port)
|
|
469
|
+
for k, v in ex.tech:
|
|
470
|
+
h.attrs["tech"].setdefault(k, v)
|
|
471
|
+
# a server/framework marker is part of the app's shape: one node
|
|
472
|
+
# per distinct value, hung off every host that advertised it
|
|
473
|
+
tid = f"tech:{v}"
|
|
474
|
+
m.add_node(tid, "tech", 1, v, header=k)
|
|
475
|
+
m.evidence(tid, i)
|
|
476
|
+
m.add_edge(host_id, tid, "RUNS", "OBSERVED", i)
|
|
477
|
+
for c in ex.set_cookies:
|
|
478
|
+
cid = f"cookie:{c['name']}"
|
|
479
|
+
cn = m.add_node(cid, "cookie", 5, c["name"], httponly=c["httponly"],
|
|
480
|
+
secure=c["secure"], samesite=c["samesite"])
|
|
481
|
+
m.evidence(cid, i)
|
|
482
|
+
m.add_edge(host_id, cid, "SETS_COOKIE", "OBSERVED", i)
|
|
483
|
+
# a cookie set with different flags in different places: keep the weakest
|
|
484
|
+
cn.attrs["httponly"] = cn.attrs["httponly"] and c["httponly"]
|
|
485
|
+
cn.attrs["secure"] = cn.attrs["secure"] and c["secure"]
|
|
486
|
+
rank = {None: 0, "none": 0, "lax": 1, "strict": 2}
|
|
487
|
+
old = (cn.attrs.get("samesite") or "").lower() or None
|
|
488
|
+
new = (c["samesite"] or "").lower() or None
|
|
489
|
+
if rank.get(new, 0) < rank.get(old, 0):
|
|
490
|
+
cn.attrs["samesite"] = c["samesite"]
|
|
491
|
+
|
|
492
|
+
kind = classify(ex)
|
|
493
|
+
if kind == "asset":
|
|
494
|
+
m.stats["static_assets"] += 1
|
|
495
|
+
continue
|
|
496
|
+
if kind == "preflight":
|
|
497
|
+
m.stats["preflight"] += 1
|
|
498
|
+
continue
|
|
499
|
+
|
|
500
|
+
label_prefix = "" if ex.host == primary else ex.host
|
|
501
|
+
if kind == "script":
|
|
502
|
+
sid = f"script:{ex.host}{ex.path_template}"
|
|
503
|
+
m.add_node(sid, "script", 3, ex.path_template.split("/")[-1] or "script.js",
|
|
504
|
+
host=ex.host, scope="first-party")
|
|
505
|
+
m.evidence(sid, i)
|
|
506
|
+
m.add_edge(host_id, sid, "LOADS", "OBSERVED", i)
|
|
507
|
+
node = m.nodes[sid]
|
|
508
|
+
elif kind == "route":
|
|
509
|
+
rid = f"route:{ex.host}{ex.path_template}"
|
|
510
|
+
node = m.add_node(rid, "route", 2, label_prefix + ex.path_template,
|
|
511
|
+
host=ex.host, path=ex.path_template, statuses=[])
|
|
512
|
+
m.evidence(rid, i)
|
|
513
|
+
m.add_edge(host_id, rid, "SERVES", "OBSERVED", i)
|
|
514
|
+
route_of_page[(ex.host, ex.path_template)] = rid
|
|
515
|
+
_add_status(node, ex.status)
|
|
516
|
+
else:
|
|
517
|
+
eid = f"endpoint:{ex.method}:{ex.host}:{ex.path_template}"
|
|
518
|
+
runtime_seen.add(eid)
|
|
519
|
+
node = m.add_node(eid, "endpoint", 4,
|
|
520
|
+
f"{ex.method} {label_prefix}{ex.path_template}",
|
|
521
|
+
method=ex.method, host=ex.host, path=ex.path_template,
|
|
522
|
+
status=ex.status, statuses=[], status_by_role={},
|
|
523
|
+
credentials=[], anonymous_requests=0, requests=0)
|
|
524
|
+
m.evidence(eid, i)
|
|
525
|
+
m.add_edge(host_id, eid, "EXPOSES", "OBSERVED", i)
|
|
526
|
+
_add_status(node, ex.status)
|
|
527
|
+
node.attrs["requests"] += 1
|
|
528
|
+
if role:
|
|
529
|
+
sbr = node.attrs["status_by_role"].setdefault(role, [])
|
|
530
|
+
if ex.status is not None and ex.status not in sbr:
|
|
531
|
+
sbr.append(ex.status)
|
|
532
|
+
sbr.sort()
|
|
533
|
+
if ex.credentials:
|
|
534
|
+
for cred in ex.credentials:
|
|
535
|
+
if cred not in node.attrs["credentials"]:
|
|
536
|
+
node.attrs["credentials"].append(cred)
|
|
537
|
+
aid = f"auth:{cred}"
|
|
538
|
+
m.add_node(aid, "auth", 5, _auth_label(cred))
|
|
539
|
+
m.evidence(aid, i)
|
|
540
|
+
m.add_edge(eid, aid, "SENT_CREDENTIAL", "OBSERVED", i)
|
|
541
|
+
else:
|
|
542
|
+
node.attrs["anonymous_requests"] += 1
|
|
543
|
+
for op in ex.gql_ops:
|
|
544
|
+
oid = f"op:{eid}:{op}"
|
|
545
|
+
m.add_node(oid, "operation", 4, op)
|
|
546
|
+
m.evidence(oid, i)
|
|
547
|
+
m.add_edge(eid, oid, "USES_OPERATION", "OBSERVED", i)
|
|
548
|
+
if role and role not in m.nodes[oid].roles:
|
|
549
|
+
m.nodes[oid].roles.append(role)
|
|
550
|
+
|
|
551
|
+
if role and role not in node.roles:
|
|
552
|
+
node.roles.append(role)
|
|
553
|
+
if role:
|
|
554
|
+
m.add_edge(f"role:{role}", node.id, "REACHED", "OBSERVED", i)
|
|
555
|
+
|
|
556
|
+
if kind in ("route", "endpoint"):
|
|
557
|
+
seen_types = {(loc, n): t for loc, n, t in ex.param_types}
|
|
558
|
+
for loc, params in (("query", ex.query_params), ("body", ex.body_params)):
|
|
559
|
+
for pname, _ in params:
|
|
560
|
+
pid = f"param:{node.id}:{pname}"
|
|
561
|
+
pn = m.add_node(pid, "parameter", 4, pname, location=loc, types=[])
|
|
562
|
+
t = seen_types.get((loc, pname))
|
|
563
|
+
if t and t not in pn.attrs.setdefault("types", []):
|
|
564
|
+
pn.attrs["types"].append(t)
|
|
565
|
+
pn.attrs["types"].sort()
|
|
566
|
+
m.evidence(pid, i)
|
|
567
|
+
m.add_edge(node.id, pid, "USES_PARAMETER", "OBSERVED", i)
|
|
568
|
+
if kind == "endpoint" and ex.resp_fields and ex.status is not None:
|
|
569
|
+
fields = node.attrs.setdefault("response_fields", {}).setdefault(str(ex.status), {})
|
|
570
|
+
for fname, ftype in ex.resp_fields:
|
|
571
|
+
types = fields.setdefault(fname, [])
|
|
572
|
+
if ftype not in types:
|
|
573
|
+
types.append(ftype)
|
|
574
|
+
types.sort()
|
|
575
|
+
|
|
576
|
+
# A path that only ever answered 404/410 is not part of the app: a scanner or a typo asked
|
|
577
|
+
# for something that is not there. Keep the requests (they are evidence), drop the nodes.
|
|
578
|
+
gone = [nid for nid, n in m.nodes.items()
|
|
579
|
+
if n.type in ("route", "endpoint") and n.attrs.get("statuses")
|
|
580
|
+
and all(st in (404, 410) for st in n.attrs["statuses"])]
|
|
581
|
+
for nid in gone:
|
|
582
|
+
del m.nodes[nid]
|
|
583
|
+
runtime_seen.discard(nid)
|
|
584
|
+
if gone:
|
|
585
|
+
dead = set(gone)
|
|
586
|
+
m.edges = [e for e in m.edges if e.src not in dead and e.dst not in dead]
|
|
587
|
+
m._edge_index = {(e.src, e.dst, e.type): e for e in m.edges}
|
|
588
|
+
for nid in [k for k in m.nodes if k.startswith("param:") and _param_of(k, dead)]:
|
|
589
|
+
del m.nodes[nid]
|
|
590
|
+
for key in [k for k, v in route_of_page.items() if v in dead]:
|
|
591
|
+
del route_of_page[key]
|
|
592
|
+
m.stats["not_found_paths_dropped"] = len(gone)
|
|
593
|
+
|
|
594
|
+
# ---- pass 2: referer edges and static references ----
|
|
595
|
+
by_key: dict[str, list[str]] = defaultdict(list)
|
|
596
|
+
for nid in sorted(runtime_seen):
|
|
597
|
+
by_key[match_key(m.nodes[nid].attrs["path"])].append(nid)
|
|
598
|
+
|
|
599
|
+
for ex in exchanges:
|
|
600
|
+
i = ex.index
|
|
601
|
+
if not in_scope(ex.host, m.scope):
|
|
602
|
+
continue
|
|
603
|
+
kind = classify(ex)
|
|
604
|
+
src = _node_for(m, ex, kind)
|
|
605
|
+
# Referer: which page was open when this request was made
|
|
606
|
+
if src and ex.referer_host and in_scope(ex.referer_host, m.scope):
|
|
607
|
+
page = route_of_page.get((ex.referer_host, ex.referer_path))
|
|
608
|
+
if page and page != src:
|
|
609
|
+
etype = {"endpoint": "CALLS", "script": "INCLUDES"}.get(kind, "NAVIGATES_TO")
|
|
610
|
+
m.add_edge(page, src, etype, "OBSERVED", i)
|
|
611
|
+
|
|
612
|
+
for ex in exchanges:
|
|
613
|
+
if not ex.js_refs or is_probe(ex):
|
|
614
|
+
continue
|
|
615
|
+
i = ex.index
|
|
616
|
+
first_party = in_scope(ex.host, m.scope)
|
|
617
|
+
kind = classify(ex) if first_party else ("script" if _is_js(ex) else None)
|
|
618
|
+
src = _node_for(m, ex, kind) if kind else None
|
|
619
|
+
if src is None:
|
|
620
|
+
continue
|
|
621
|
+
# relative references run against the page origin, not the script host
|
|
622
|
+
base_host = ex.host
|
|
623
|
+
if not in_scope(base_host, m.scope) or kind == "script":
|
|
624
|
+
base_host = (ex.referer_host if ex.referer_host and in_scope(ex.referer_host, m.scope)
|
|
625
|
+
else primary)
|
|
626
|
+
for method, host, path, _ref_kind in ex.js_refs:
|
|
627
|
+
var_base = host == VAR_BASE
|
|
628
|
+
if var_base:
|
|
629
|
+
host = None # matched against any host; never pinned to the page's
|
|
630
|
+
if host and not in_scope(host, m.scope):
|
|
631
|
+
tp_id = f"host:{host}"
|
|
632
|
+
m.add_node(tp_id, "third_party", 5, host, scope="external", requests=0)
|
|
633
|
+
m.evidence(tp_id, i)
|
|
634
|
+
m.add_edge(src, tp_id, "REFERENCES", "INFERRED", i)
|
|
635
|
+
continue
|
|
636
|
+
if host is None and not first_party:
|
|
637
|
+
continue # vendor code: its relative paths are the vendor's business
|
|
638
|
+
cands = [nid for nid in by_key.get(match_key(path), [])
|
|
639
|
+
if (host is None or m.nodes[nid].attrs["host"] == host)
|
|
640
|
+
and (method is None or m.nodes[nid].attrs["method"] == method)]
|
|
641
|
+
if cands:
|
|
642
|
+
for nid in cands:
|
|
643
|
+
referenced.add(nid)
|
|
644
|
+
m.evidence(nid, i)
|
|
645
|
+
m.add_edge(src, nid, "REFERENCES", "INFERRED", i)
|
|
646
|
+
continue
|
|
647
|
+
target_host = VAR_BASE if var_base else host or base_host
|
|
648
|
+
meth = method or "*"
|
|
649
|
+
eid = f"endpoint:{meth}:{target_host}:{path}"
|
|
650
|
+
prefix = "" if target_host == primary else target_host
|
|
651
|
+
node = m.add_node(eid, "endpoint", 4, f"{meth} {prefix}{path}",
|
|
652
|
+
method=method, host=target_host, path=path,
|
|
653
|
+
method_known=method is not None, statuses=[],
|
|
654
|
+
status_by_role={}, credentials=[],
|
|
655
|
+
anonymous_requests=0, requests=0)
|
|
656
|
+
m.evidence(eid, i)
|
|
657
|
+
referenced.add(eid)
|
|
658
|
+
m.add_edge(src, eid, "REFERENCES", "INFERRED", i)
|
|
659
|
+
|
|
660
|
+
_link_script_assets(m, exchanges)
|
|
661
|
+
m.stack = _build_stack(m, exchanges)
|
|
662
|
+
_reconcile(m, referenced, runtime_seen)
|
|
663
|
+
_emit_unknowns(m, exchanges)
|
|
664
|
+
for n in m.nodes.values():
|
|
665
|
+
n.evidence.sort()
|
|
666
|
+
for e in m.edges:
|
|
667
|
+
e.evidence.sort()
|
|
668
|
+
return m
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _node_for(m: Model, ex: Exchange, kind: str | None) -> str | None:
|
|
672
|
+
if kind == "script":
|
|
673
|
+
nid = f"script:{ex.host}{ex.path_template}"
|
|
674
|
+
elif kind == "route":
|
|
675
|
+
nid = f"route:{ex.host}{ex.path_template}"
|
|
676
|
+
elif kind == "endpoint":
|
|
677
|
+
nid = f"endpoint:{ex.method}:{ex.host}:{ex.path_template}"
|
|
678
|
+
else:
|
|
679
|
+
return None
|
|
680
|
+
return nid if nid in m.nodes else None
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _add_status(node: Node, status) -> None:
|
|
684
|
+
if status is not None and status not in node.attrs["statuses"]:
|
|
685
|
+
node.attrs["statuses"].append(status)
|
|
686
|
+
node.attrs["statuses"].sort()
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _auth_label(cred: str) -> str:
|
|
690
|
+
if cred.startswith("cookie:"):
|
|
691
|
+
return f"session cookie {cred[7:]}"
|
|
692
|
+
if cred.startswith("header:"):
|
|
693
|
+
return f"credential header {cred[7:]}"
|
|
694
|
+
if cred.startswith("query:"):
|
|
695
|
+
return f"credential in query {cred[6:]}"
|
|
696
|
+
return f"Authorization: {cred}"
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _add_secret(m: Model, s: dict, idx: int) -> None:
|
|
700
|
+
key = (s["kind"], s["hmac_12"])
|
|
701
|
+
existing = m._secret_index.get(key)
|
|
702
|
+
if existing is not None:
|
|
703
|
+
existing["count"] += 1
|
|
704
|
+
if idx not in existing["evidence"] and len(existing["evidence"]) < 10:
|
|
705
|
+
existing["evidence"].append(idx)
|
|
706
|
+
return
|
|
707
|
+
entry = {**s, "count": 1, "evidence": [idx]}
|
|
708
|
+
m._secret_index[key] = entry
|
|
709
|
+
m.secrets.append(entry)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _build_stack(m: Model, exchanges: list[Exchange]) -> list[dict]:
|
|
713
|
+
"""The technologies the capture shows, each tied to the requests that showed it."""
|
|
714
|
+
from .stack import CATEGORIES, SERVICE_KIND, vendors_for
|
|
715
|
+
agg: dict[tuple[str, str], dict] = {}
|
|
716
|
+
|
|
717
|
+
def entry(name, cat, how, ver="", kind=""):
|
|
718
|
+
e = agg.setdefault((cat, name), {"name": name, "category": cat, "kind": kind,
|
|
719
|
+
"version": "", "how": how, "evidence": []})
|
|
720
|
+
if ver and not e["version"]:
|
|
721
|
+
e["version"] = ver
|
|
722
|
+
return e
|
|
723
|
+
|
|
724
|
+
for ex in exchanges:
|
|
725
|
+
if is_probe(ex) or not in_scope(ex.host, m.scope):
|
|
726
|
+
continue
|
|
727
|
+
for name, cat, how, ver in ex.stack:
|
|
728
|
+
e = entry(name, cat, how, ver, SERVICE_KIND.get(name, ""))
|
|
729
|
+
if ex.index not in e["evidence"] and len(e["evidence"]) < 10:
|
|
730
|
+
e["evidence"].append(ex.index)
|
|
731
|
+
tps = [n for n in m.nodes.values() if n.type == "third_party"]
|
|
732
|
+
by_host = {n.label: n for n in tps}
|
|
733
|
+
for vendor, kind, host in vendors_for(sorted(by_host)):
|
|
734
|
+
cat = "Edge & CDN" if kind == "Edge & CDN" else "Services"
|
|
735
|
+
e = entry(vendor, cat, "third-party host", "", "" if cat == "Edge & CDN" else kind)
|
|
736
|
+
for i in by_host[host].evidence[:10]:
|
|
737
|
+
if i not in e["evidence"] and len(e["evidence"]) < 10:
|
|
738
|
+
e["evidence"].append(i)
|
|
739
|
+
rank = {c: i for i, c in enumerate(CATEGORIES)}
|
|
740
|
+
out = sorted(agg.values(), key=lambda e: (rank.get(e["category"], 99), e["name"].lower()))
|
|
741
|
+
for e in out:
|
|
742
|
+
e["evidence"].sort()
|
|
743
|
+
return out
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _link_script_assets(m: Model, exchanges: list[Exchange]) -> None:
|
|
747
|
+
"""Layer 3: workers a script spawns, and the source map it names (INFERRED from code)."""
|
|
748
|
+
seen = {(ex.host, ex.path_template) for ex in exchanges
|
|
749
|
+
if ex.status is not None and ex.status < 400 and not is_probe(ex)}
|
|
750
|
+
for ex in exchanges:
|
|
751
|
+
if not ex.js_assets or is_probe(ex) or not in_scope(ex.host, m.scope):
|
|
752
|
+
continue
|
|
753
|
+
src = f"script:{ex.host}{ex.path_template}"
|
|
754
|
+
if src not in m.nodes:
|
|
755
|
+
continue
|
|
756
|
+
for kind, host, path in ex.js_assets:
|
|
757
|
+
if not in_scope(host, m.scope):
|
|
758
|
+
continue
|
|
759
|
+
if kind == "sourcemap":
|
|
760
|
+
captured = (host, path) in seen
|
|
761
|
+
m.nodes[src].attrs["sourcemap"] = {"host": host, "path": path, "captured": captured}
|
|
762
|
+
m.evidence(src, ex.index)
|
|
763
|
+
if not captured:
|
|
764
|
+
m.unknowns.append(Unknown(
|
|
765
|
+
"SOURCE_MAP_NOT_CAPTURED", src,
|
|
766
|
+
[f"the script names a source map ({path}) that is not in the capture"],
|
|
767
|
+
"original source file names and structure",
|
|
768
|
+
"request the .map file in an authorized session and re-capture",
|
|
769
|
+
))
|
|
770
|
+
continue
|
|
771
|
+
wid = f"script:{host}{path}"
|
|
772
|
+
node = m.add_node(wid, "script", 3, path.split("/")[-1] or "worker.js",
|
|
773
|
+
host=host, scope="first-party")
|
|
774
|
+
node.attrs.setdefault("worker", kind)
|
|
775
|
+
m.evidence(wid, ex.index)
|
|
776
|
+
m.add_edge(src, wid, "SPAWNS", "INFERRED", ex.index)
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _reconcile(m: Model, referenced: set[str], runtime_seen: set[str]) -> None:
|
|
780
|
+
"""Label each endpoint STATIC_ONLY / RUNTIME_ONLY / BOTH."""
|
|
781
|
+
for nid, node in m.nodes.items():
|
|
782
|
+
if node.type != "endpoint":
|
|
783
|
+
continue
|
|
784
|
+
in_js = nid in referenced
|
|
785
|
+
in_rt = nid in runtime_seen
|
|
786
|
+
if in_js and in_rt:
|
|
787
|
+
node.attrs["api_state"] = "BOTH"
|
|
788
|
+
elif in_js:
|
|
789
|
+
node.attrs["api_state"] = "STATIC_ONLY"
|
|
790
|
+
else:
|
|
791
|
+
node.attrs["api_state"] = "RUNTIME_ONLY"
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _emit_unknowns(m: Model, exchanges: list[Exchange]) -> None:
|
|
795
|
+
creds_seen = any(ex.credentials for ex in exchanges)
|
|
796
|
+
if not m.roles and not creds_seen:
|
|
797
|
+
m.unknowns.append(Unknown(
|
|
798
|
+
"AUTHENTICATED_STATE_NOT_OBSERVED", f"app:{m.name}",
|
|
799
|
+
["no credentials were sent in the capture", "capture had no role tag"],
|
|
800
|
+
"how the app behaves for a logged-in user",
|
|
801
|
+
"re-ingest an authenticated capture with --role",
|
|
802
|
+
))
|
|
803
|
+
elif not m.roles:
|
|
804
|
+
m.unknowns.append(Unknown(
|
|
805
|
+
"ROLE_NOT_TAGGED", f"app:{m.name}",
|
|
806
|
+
["credentials were sent", "capture had no role tag"],
|
|
807
|
+
"which user/role the traffic belongs to, so access cannot be compared",
|
|
808
|
+
"re-ingest each account's capture with --role <name>",
|
|
809
|
+
))
|
|
810
|
+
if m.stats.get("skipped"):
|
|
811
|
+
m.unknowns.append(Unknown(
|
|
812
|
+
"CAPTURE_ITEMS_SKIPPED", f"app:{m.name}",
|
|
813
|
+
[f"{m.stats['skipped']} export item(s) could not be parsed"],
|
|
814
|
+
"what those requests contained",
|
|
815
|
+
"re-export the history; check for truncated or non-Burp XML",
|
|
816
|
+
))
|
|
817
|
+
truncated = {f"script:{ex.host}{ex.path_template}" for ex in exchanges if ex.scan_truncated}
|
|
818
|
+
for sid in sorted(truncated):
|
|
819
|
+
if sid in m.nodes:
|
|
820
|
+
m.unknowns.append(Unknown(
|
|
821
|
+
"SCRIPT_PARTIALLY_SCANNED", sid,
|
|
822
|
+
["script body exceeded the scan cap"],
|
|
823
|
+
"endpoints referenced past the cap",
|
|
824
|
+
"extract the bundle and scan it separately",
|
|
825
|
+
))
|
|
826
|
+
for node in m.nodes.values():
|
|
827
|
+
if node.type != "endpoint":
|
|
828
|
+
continue
|
|
829
|
+
state = node.attrs.get("api_state")
|
|
830
|
+
if state == "STATIC_ONLY":
|
|
831
|
+
know = ["referenced in code", "never observed at runtime"]
|
|
832
|
+
if not node.attrs.get("method_known", True):
|
|
833
|
+
know.append("HTTP method not determinable from the reference")
|
|
834
|
+
m.unknowns.append(Unknown(
|
|
835
|
+
"API_PURPOSE_UNKNOWN", node.id, know,
|
|
836
|
+
"what this endpoint does and whether it is reachable",
|
|
837
|
+
"request it directly in an authorised session",
|
|
838
|
+
))
|
|
839
|
+
statuses = node.attrs.get("statuses") or []
|
|
840
|
+
if statuses and all(s >= 400 for s in statuses):
|
|
841
|
+
m.unknowns.append(Unknown(
|
|
842
|
+
"ENDPOINT_ONLY_ERRORED", node.id,
|
|
843
|
+
[f"observed statuses: {', '.join(map(str, statuses))}"],
|
|
844
|
+
"how it behaves with valid input and authorization",
|
|
845
|
+
"exercise the feature normally in an authorised session",
|
|
846
|
+
))
|
|
847
|
+
if node.roles:
|
|
848
|
+
m.unknowns.append(Unknown(
|
|
849
|
+
"AUTHORIZATION_UNKNOWN", node.id,
|
|
850
|
+
[f"reached by role(s): {', '.join(node.roles)}"],
|
|
851
|
+
"whether access is authorization-checked",
|
|
852
|
+
"replay across two in-scope accounts and compare",
|
|
853
|
+
))
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
# ------------------------------------------------------------- cross-role -----
|
|
857
|
+
|
|
858
|
+
def _ok(statuses) -> bool:
|
|
859
|
+
return any(200 <= s < 300 for s in statuses or [])
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def cross_role(m: Model, low: str, high: str) -> dict:
|
|
863
|
+
"""Compare what a low and a high role reached. Output is hypotheses.
|
|
864
|
+
|
|
865
|
+
high_only endpoints the high role reached that the low role
|
|
866
|
+
never requested — replay each as the low role.
|
|
867
|
+
low_reached_privileged endpoints the low role reached whose path looks
|
|
868
|
+
privileged — check the response was meant for it.
|
|
869
|
+
shared_same_success both roles got a 2xx — compare response content.
|
|
870
|
+
"""
|
|
871
|
+
high_only, low_priv, shared = [], [], []
|
|
872
|
+
ev_role = {e["id"]: e.get("role") for e in m.evidence_log.meta()}
|
|
873
|
+
|
|
874
|
+
def evs(node, prefer_role):
|
|
875
|
+
# cite the preferred role's requests when we have them, so a
|
|
876
|
+
# "low reached privileged" line never points at the high role's request
|
|
877
|
+
ids = [i for i in node.evidence if ev_role.get(i) == prefer_role]
|
|
878
|
+
return (ids or node.evidence)[:3]
|
|
879
|
+
|
|
880
|
+
for node in m.nodes.values():
|
|
881
|
+
if node.type not in ("endpoint", "operation"):
|
|
882
|
+
continue
|
|
883
|
+
rl, rh = low in node.roles, high in node.roles
|
|
884
|
+
path = node.attrs.get("path", node.label)
|
|
885
|
+
sbr = node.attrs.get("status_by_role", {})
|
|
886
|
+
entry = {"endpoint": node.label, "id": node.id,
|
|
887
|
+
"evidence": evs(node, low if rl else high),
|
|
888
|
+
"statuses": {r: sbr.get(r, []) for r in (low, high) if r in sbr}}
|
|
889
|
+
if rh and not rl:
|
|
890
|
+
high_only.append({**entry, "kind": "high_only",
|
|
891
|
+
"unknown": f"whether '{low}' can reach it",
|
|
892
|
+
"next": f"replay as '{low}' and compare with '{high}'"})
|
|
893
|
+
elif rl and PRIV_RE.search(path):
|
|
894
|
+
low_priv.append({**entry, "kind": "low_reached_privileged",
|
|
895
|
+
"unknown": "whether the low-role response was authorised or a leak",
|
|
896
|
+
"next": f"compare '{low}' and '{high}' response bodies"})
|
|
897
|
+
elif rl and rh and _ok(sbr.get(low)) and _ok(sbr.get(high)):
|
|
898
|
+
shared.append({**entry, "kind": "shared_same_success",
|
|
899
|
+
"unknown": "whether both roles see the same data",
|
|
900
|
+
"next": "diff the two responses for role-specific fields"})
|
|
901
|
+
key = lambda e: e["endpoint"]
|
|
902
|
+
high_only.sort(key=key)
|
|
903
|
+
low_priv.sort(key=key)
|
|
904
|
+
shared.sort(key=key)
|
|
905
|
+
return {"low": low, "high": high,
|
|
906
|
+
"roles_present": sorted(m.roles),
|
|
907
|
+
"surface": low_priv + high_only,
|
|
908
|
+
"low_reached_privileged": low_priv,
|
|
909
|
+
"high_only": high_only,
|
|
910
|
+
"shared_same_success": shared}
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
# ------------------------------------------------------------ serialise -------
|
|
914
|
+
|
|
915
|
+
# Version of the model.json shape. Bump on any breaking change.
|
|
916
|
+
MODEL_SCHEMA_VERSION = 1
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def to_dict(m: Model, evidence: bool = True) -> dict:
|
|
920
|
+
"""The model as plain data. `evidence=False` leaves the request log out (dump_model streams it)."""
|
|
921
|
+
from .. import __version__
|
|
922
|
+
d = {
|
|
923
|
+
"tool": "proxy2model",
|
|
924
|
+
"version": __version__,
|
|
925
|
+
"schema": MODEL_SCHEMA_VERSION,
|
|
926
|
+
"app": m.name,
|
|
927
|
+
"scope": m.scope,
|
|
928
|
+
"counts": m.counts(),
|
|
929
|
+
"stats": m.stats,
|
|
930
|
+
"nodes": [asdict(n) for n in m.nodes.values()],
|
|
931
|
+
"edges": [asdict(e) for e in m.edges],
|
|
932
|
+
"unknowns": [asdict(u) for u in m.unknowns],
|
|
933
|
+
"roles": sorted(m.roles),
|
|
934
|
+
"secrets": m.secrets,
|
|
935
|
+
"evidence": list(m.evidence_log) if evidence else [],
|
|
936
|
+
"stack": m.stack,
|
|
937
|
+
}
|
|
938
|
+
return d
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def dump_model(m: Model, indent: int | None = None, ensure_ascii: bool = True):
|
|
942
|
+
"""Yield the text of json.dumps(to_dict(m), indent=indent) piece by piece. The request log, by far the
|
|
943
|
+
largest part, is unpacked and written one entry at a time instead of existing as one huge list."""
|
|
944
|
+
kw = {"indent": indent, "ensure_ascii": ensure_ascii}
|
|
945
|
+
pad = " " * indent if indent else ""
|
|
946
|
+
d = to_dict(m, evidence=False)
|
|
947
|
+
keys = list(d)
|
|
948
|
+
yield "{\n" if indent else "{"
|
|
949
|
+
for n, k in enumerate(keys):
|
|
950
|
+
yield (pad if indent else "") + json.dumps(k, ensure_ascii=ensure_ascii) + ": "
|
|
951
|
+
if k != "evidence":
|
|
952
|
+
yield json.dumps(d[k], **kw).replace("\n", "\n" + pad)
|
|
953
|
+
elif not len(m.evidence_log):
|
|
954
|
+
yield "[]"
|
|
955
|
+
else:
|
|
956
|
+
yield "[\n" if indent else "["
|
|
957
|
+
for i, e in enumerate(m.evidence_log):
|
|
958
|
+
if i:
|
|
959
|
+
yield ",\n" if indent else ", "
|
|
960
|
+
txt = json.dumps(e, **kw)
|
|
961
|
+
yield (pad * 2 + txt.replace("\n", "\n" + pad * 2)) if indent else txt
|
|
962
|
+
yield ("\n" + pad + "]") if indent else "]"
|
|
963
|
+
if n < len(keys) - 1:
|
|
964
|
+
yield ",\n" if indent else ", "
|
|
965
|
+
yield "\n}" if indent else "}"
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def from_dict(data: dict) -> Model:
|
|
969
|
+
"""Rebuild a Model from model.json (for query / cross-role / animate)."""
|
|
970
|
+
if not isinstance(data, dict) or "app" not in data or "nodes" not in data:
|
|
971
|
+
raise ValueError("not a proxy2model model.json (missing app/nodes)")
|
|
972
|
+
schema = data.get("schema", 1)
|
|
973
|
+
if isinstance(schema, int) and schema > MODEL_SCHEMA_VERSION:
|
|
974
|
+
raise ValueError(
|
|
975
|
+
f"model.json schema {schema} is newer than this proxy2model "
|
|
976
|
+
f"(understands {MODEL_SCHEMA_VERSION}); upgrade the package")
|
|
977
|
+
m = Model(data["app"])
|
|
978
|
+
for nd in data["nodes"]:
|
|
979
|
+
n = Node(**{k: nd[k] for k in ("id", "type", "layer", "label")},
|
|
980
|
+
attrs=nd.get("attrs", {}), evidence=nd.get("evidence", []),
|
|
981
|
+
roles=nd.get("roles", []))
|
|
982
|
+
m.nodes[n.id] = n
|
|
983
|
+
for ed in data["edges"]:
|
|
984
|
+
e = Edge(**ed)
|
|
985
|
+
m.edges.append(e)
|
|
986
|
+
m._edge_index[(e.src, e.dst, e.type)] = e
|
|
987
|
+
for u in data["unknowns"]:
|
|
988
|
+
m.unknowns.append(Unknown(**u))
|
|
989
|
+
m.roles = set(data.get("roles", []))
|
|
990
|
+
m.secrets = data.get("secrets", [])
|
|
991
|
+
for s in m.secrets:
|
|
992
|
+
if "kind" in s and "hmac_12" in s:
|
|
993
|
+
m._secret_index[(s["kind"], s["hmac_12"])] = s
|
|
994
|
+
m.scope = data.get("scope", [])
|
|
995
|
+
m.stats = data.get("stats", {})
|
|
996
|
+
m.evidence_log = EvidenceLog.from_entries(data.get("evidence", []))
|
|
997
|
+
m.stack = data.get("stack", [])
|
|
998
|
+
return m
|