proxy2model 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. proxy2model/__init__.py +19 -0
  2. proxy2model/__main__.py +6 -0
  3. proxy2model/atomic.py +68 -0
  4. proxy2model/cli/__init__.py +3 -0
  5. proxy2model/cli/analyze.py +501 -0
  6. proxy2model/cli/app.py +408 -0
  7. proxy2model/cli/browse.py +279 -0
  8. proxy2model/cli/build.py +399 -0
  9. proxy2model/cli/common.py +190 -0
  10. proxy2model/cli/history.py +224 -0
  11. proxy2model/cli/mcp.py +184 -0
  12. proxy2model/cli/ui.py +62 -0
  13. proxy2model/core/__init__.py +1 -0
  14. proxy2model/core/changes.py +124 -0
  15. proxy2model/core/context.py +571 -0
  16. proxy2model/core/diff.py +330 -0
  17. proxy2model/core/domains.py +98 -0
  18. proxy2model/core/graph.py +562 -0
  19. proxy2model/core/methodology.py +343 -0
  20. proxy2model/core/model.py +998 -0
  21. proxy2model/core/stack.py +218 -0
  22. proxy2model/data/fonts/LICENSES.md +16 -0
  23. proxy2model/data/fonts/inter-latin-wght-normal.woff2 +0 -0
  24. proxy2model/data/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  25. proxy2model/data/fonts/nippo-200.woff2 +0 -0
  26. proxy2model/data/fonts/nippo-300.woff2 +0 -0
  27. proxy2model/data/fonts/nippo-400.woff2 +0 -0
  28. proxy2model/data/fonts/nippo-500.woff2 +0 -0
  29. proxy2model/data/fonts/nippo-700.woff2 +0 -0
  30. proxy2model/data/public_suffix_list.dat +16497 -0
  31. proxy2model/db/__init__.py +1 -0
  32. proxy2model/db/bql.py +710 -0
  33. proxy2model/db/bql_js.py +278 -0
  34. proxy2model/db/store.py +547 -0
  35. proxy2model/export/__init__.py +1 -0
  36. proxy2model/export/openapi.py +248 -0
  37. proxy2model/ingest/__init__.py +1 -0
  38. proxy2model/ingest/browse.py +181 -0
  39. proxy2model/ingest/browser.py +1058 -0
  40. proxy2model/ingest/crawl.py +673 -0
  41. proxy2model/ingest/extract.py +208 -0
  42. proxy2model/ingest/infer.py +75 -0
  43. proxy2model/ingest/journey.py +148 -0
  44. proxy2model/ingest/live.py +96 -0
  45. proxy2model/ingest/parse.py +1275 -0
  46. proxy2model/ingest/redact.py +535 -0
  47. proxy2model/py.typed +0 -0
  48. proxy2model/recon/__init__.py +1 -0
  49. proxy2model/recon/osint.py +607 -0
  50. proxy2model/report/__init__.py +1 -0
  51. proxy2model/report/animate.py +163 -0
  52. proxy2model/report/dash.py +469 -0
  53. proxy2model/report/fonts.py +51 -0
  54. proxy2model/report/html.py +988 -0
  55. proxy2model/report/inventory.py +258 -0
  56. proxy2model/report/map.py +515 -0
  57. proxy2model/report/pages.py +537 -0
  58. proxy2model/report/query.py +306 -0
  59. proxy2model/report/shot.py +55 -0
  60. proxy2model/report/view.py +213 -0
  61. proxy2model-2.0.0.dist-info/METADATA +148 -0
  62. proxy2model-2.0.0.dist-info/RECORD +66 -0
  63. proxy2model-2.0.0.dist-info/WHEEL +5 -0
  64. proxy2model-2.0.0.dist-info/entry_points.txt +2 -0
  65. proxy2model-2.0.0.dist-info/licenses/LICENSE +201 -0
  66. proxy2model-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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