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,988 @@
1
+ """
2
+ The proxy2model report: a single self-contained, offline, interactive HTML
3
+ dashboard built from the model.
4
+
5
+ Design goals, in order:
6
+ * safe — every string in the model came from the target, so the embedded JSON
7
+ is escaped so it cannot close its <script>, the page carries a strict CSP,
8
+ and all model-derived text is inserted through an escaping helper, never as
9
+ raw HTML.
10
+ * offline — no network, no CDN, no fonts to fetch; opens from disk.
11
+ * useful — multiple views (Overview, Priorities, Attack surface, Client code,
12
+ Supply chain, Trust, Cross-role, Evidence), faceted filters, global search,
13
+ and an evidence drawer that resolves every ev_N to its request.
14
+
15
+ The Python side builds one flat, view-agnostic payload; all rendering, filtering
16
+ and searching happens client-side from that payload.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import time
24
+
25
+ from .. import __version__
26
+ from ..atomic import atomic_write
27
+ from ..core.context import context_package
28
+ from ..core.methodology import METHODOLOGY_PROMPT, investigation_plan
29
+ from ..core.model import PRIV_RE, TELEMETRY_RE, Model, to_dict
30
+ from ..db.bql_js import BQL_JS
31
+ from .dash import DASH_CSS, DASH_JS
32
+ from .view import RR_CSS, RR_JS
33
+ from .pages import PG_CSS, PG_JS
34
+ from .inventory import INV_CSS, INV_JS
35
+ from .query import QRY_CSS, QRY_JS
36
+ from .map import MAP_CSS, MAP_JS
37
+ from .fonts import TYPE_CSS, font_css
38
+
39
+ # Version of the JSON embedded in report.html. Bump on any breaking change to
40
+ # the payload shape so the page's code and the data can't silently drift.
41
+ SCHEMA_VERSION = 2
42
+
43
+
44
+ def _escape_script(s: str) -> str:
45
+ """Make a piece of JSON safe to embed inside an inline <script> element."""
46
+ return (s.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
47
+ .replace("\u2028", "\\u2028").replace("\u2029", "\\u2029"))
48
+
49
+
50
+ def _safe_json(obj) -> str:
51
+ """JSON safe to embed inside an inline <script> element."""
52
+ return _escape_script(json.dumps(obj, ensure_ascii=False, sort_keys=False))
53
+
54
+
55
+ def _params_index(m: Model) -> dict[str, list[str]]:
56
+ out: dict[str, list[str]] = {}
57
+ for e in m.edges:
58
+ if e.type == "USES_PARAMETER" and e.dst in m.nodes:
59
+ out.setdefault(e.src, []).append(m.nodes[e.dst].label)
60
+ return {k: sorted(set(v)) for k, v in out.items()}
61
+
62
+
63
+ def _osint_graph(osint: dict, primary_host: str | None, node_ids: set[str] | None = None):
64
+ """Turn an osint.json report into map nodes/edges hung off the app host."""
65
+ nodes, edges = [], []
66
+ if not osint:
67
+ return nodes, edges, None
68
+ host = osint.get("host") or primary_host
69
+ anchor = f"host:{host}" if host else None
70
+ # never emit edges to a node the graph does not hold
71
+ if node_ids is not None and anchor not in node_ids:
72
+ anchor = f"host:{primary_host}" if f"host:{primary_host}" in node_ids else None
73
+ summary = {"host": host}
74
+
75
+ # External recon is a different provenance from the capture, so its edges
76
+ # carry their own state (EXTERNAL) and the map draws them distinctly —
77
+ # never as "seen in traffic" or "found in code".
78
+ def add(nid, label, detail, kind="fact", extra_edge=True):
79
+ nodes.append({"id": nid, "label": label, "type": "infra", "layer": 5,
80
+ "state": None, "evidence": [], "detail": detail, "kind": kind})
81
+ if anchor and extra_edge:
82
+ edges.append({"s": anchor, "d": nid, "type": "OSINT", "state": "EXTERNAL"})
83
+
84
+ tls = osint.get("tls") or {}
85
+ if tls.get("issuer") and not tls.get("error"):
86
+ exp = tls.get("not_after")
87
+ days = tls.get("days_until_expiry")
88
+ add("infra:tls", f"TLS · {tls['issuer']}",
89
+ f"Issuer: {tls['issuer']}"
90
+ + (f" · expires {exp}" if exp else "")
91
+ + (f" ({days} days)" if days is not None else "")
92
+ + (f" · {tls['tls_version']}" if tls.get("tls_version") else "")
93
+ + (f" · {len(tls['san'])} SANs" if tls.get("san") else ""), "tls")
94
+ summary["tls"] = tls
95
+ dns = osint.get("dns") or {}
96
+ for ip in (dns.get("A") or [])[:3]:
97
+ add(f"infra:ip:{ip}", f"IP · {ip}", f"DNS A record: {ip}", "dns")
98
+ if dns.get("A"):
99
+ summary["ips"] = dns["A"][:3]
100
+ if dns.get("CNAME"):
101
+ summary["cname"] = dns["CNAME"][0]
102
+ if dns.get("CNAME"):
103
+ add("infra:cname", f"CNAME · {dns['CNAME'][0]}",
104
+ "Canonical name: " + ", ".join(dns["CNAME"][:3]), "dns")
105
+ if dns.get("NS"):
106
+ add("infra:ns", f"Nameservers · {len(dns['NS'])}", "NS: " + ", ".join(dns["NS"][:4]), "dns")
107
+ summary["nameservers"] = dns["NS"][:4]
108
+ if dns.get("MX"):
109
+ add("infra:mx", "Mail (MX)", "MX: " + ", ".join(dns["MX"][:3]), "dns")
110
+ geo = osint.get("ip_geo") or {}
111
+ if geo.get("network"):
112
+ add("infra:host", f"Hosting · {geo['network']}",
113
+ f"Network: {geo['network']}" + (f" · {geo.get('country')}" if geo.get("country") else ""),
114
+ "hosting")
115
+ summary["hosting"] = geo
116
+ for t in (osint.get("technology") or [])[:8]:
117
+ name = t.get("name") if isinstance(t, dict) else t
118
+ via = t.get("evidence") if isinstance(t, dict) else None
119
+ add(f"infra:tech:{name}", f"Tech · {name}",
120
+ f"Technology: {name}" + (f" · seen in {via}" if via else ""), "tech")
121
+ http = osint.get("http") or {}
122
+ missing = http.get("security_headers_missing") or []
123
+ if not http.get("error") and (missing or http.get("security_headers_present")):
124
+ present = len(http.get("security_headers_present") or {})
125
+ add("infra:headers", f"Security headers · {len(missing)} missing",
126
+ f"{present} present, {len(missing)} missing"
127
+ + (": " + ", ".join(missing[:6]) if missing else ""), "http")
128
+ summary["headers_missing"] = missing
129
+ summary["headers_present"] = sorted((http.get("security_headers_present") or {}).keys())
130
+ em = osint.get("email_security") or {}
131
+ if em.get("dmarc_policy") or em.get("spf_note"):
132
+ add("infra:email", "Email auth",
133
+ f"SPF: {em.get('spf_note','?')} · DMARC: {em.get('dmarc_policy') or em.get('dmarc_note','?')}",
134
+ "email")
135
+ summary["email_security"] = em
136
+ subs = osint.get("subdomains") or {}
137
+ if subs.get("count"):
138
+ names = subs.get("names") or []
139
+ add("infra:subs", f"Subdomains · {subs['count']}",
140
+ f"{subs['count']} names from certificate transparency"
141
+ + (": " + ", ".join(names[:6]) + (" …" if len(names) > 6 else "") if names else ""),
142
+ "dns")
143
+ summary["subdomains"] = subs.get("count")
144
+ reg = osint.get("registration") or {}
145
+ if reg.get("registrar") and not reg.get("error"):
146
+ add("infra:registrar", f"Registrar · {reg['registrar']}",
147
+ f"Registered {reg.get('registration') or '?'} · expires {reg.get('expiration') or '?'}",
148
+ "registration")
149
+ summary["registrar"] = reg["registrar"]
150
+ ports = (osint.get("ports") or {}).get("open") or []
151
+ if ports:
152
+ add("infra:ports", f"Open ports · {len(ports)}",
153
+ "TCP connect: " + ", ".join(f"{p['port']}/{p.get('service','?')}" for p in ports[:8]),
154
+ "ports")
155
+ summary["open_ports"] = [p["port"] for p in ports]
156
+ robots = ((osint.get("files") or {}).get("robots")) or {}
157
+ if robots.get("present"):
158
+ dis = robots.get("disallow") or []
159
+ add("infra:robots", f"robots.txt · {len(dis)} disallow",
160
+ ("Disallow: " + ", ".join(dis[:6])) if dis else "robots.txt present, no Disallow rules",
161
+ "http")
162
+ arch = osint.get("archive") or {}
163
+ if arch.get("archived"):
164
+ add("infra:archive", "Wayback snapshot", f"Last archived {arch.get('last_snapshot')}", "http")
165
+ summary["technology"] = [t.get("name") if isinstance(t, dict) else t
166
+ for t in (osint.get("technology") or [])]
167
+ return nodes, edges, summary
168
+
169
+
170
+ _EVIDENCE = "\u0000evidence" # stands in for the request log until it is streamed out
171
+
172
+
173
+ def build_payload(m: Model, osint: dict | None = None, lens: str = "attention",
174
+ lazy_evidence: bool = False) -> dict:
175
+ data = to_dict(m, evidence=not lazy_evidence)
176
+ nodes = {n["id"]: n for n in data["nodes"]}
177
+ params = _params_index(m)
178
+
179
+ refs_in: dict[str, list[str]] = {} # endpoint id -> script/route labels referencing it
180
+ calls_in: dict[str, list[str]] = {} # endpoint id -> route labels calling it (OBSERVED)
181
+ script_refs: dict[str, list[str]] = {} # script id -> endpoint labels
182
+ route_calls: dict[str, list[str]] = {} # route id -> endpoint labels
183
+ route_scripts: dict[str, list[str]] = {}
184
+ tp_refs: dict[str, list[str]] = {} # third_party id -> referrer labels
185
+ auth_eps: dict[str, list[str]] = {} # auth id -> endpoint labels
186
+ for e in data["edges"]:
187
+ src, dst = nodes.get(e["src"]), nodes.get(e["dst"])
188
+ if not src or not dst:
189
+ continue
190
+ if e["type"] == "REFERENCES":
191
+ if dst["type"] == "endpoint":
192
+ refs_in.setdefault(dst["id"], []).append(src["label"])
193
+ if src["type"] == "script":
194
+ script_refs.setdefault(src["id"], []).append(dst["label"])
195
+ elif dst["type"] == "third_party":
196
+ tp_refs.setdefault(dst["id"], []).append(src["label"])
197
+ elif e["type"] == "CALLS" and dst["type"] == "endpoint":
198
+ calls_in.setdefault(dst["id"], []).append(src["label"])
199
+ route_calls.setdefault(src["id"], []).append(dst["label"])
200
+ elif e["type"] == "INCLUDES" and dst["type"] == "script":
201
+ route_scripts.setdefault(src["id"], []).append(dst["label"])
202
+ elif e["type"] == "SENT_CREDENTIAL" and src["type"] == "endpoint":
203
+ auth_eps.setdefault(dst["id"], []).append(src["label"])
204
+
205
+ def uniq(xs):
206
+ return sorted(set(xs))
207
+
208
+ endpoints = []
209
+ for n in data["nodes"]:
210
+ if n["type"] != "endpoint":
211
+ continue
212
+ a = n["attrs"]
213
+ endpoints.append({
214
+ "id": n["id"], "label": n["label"], "method": a.get("method") or "*",
215
+ "host": a.get("host"), "path": a.get("path"), "state": a.get("api_state"),
216
+ "statuses": a.get("statuses") or [], "status_by_role": a.get("status_by_role") or {},
217
+ "credentials": a.get("credentials") or [], "roles": n["roles"],
218
+ "anon": bool(a.get("anonymous_requests") and a.get("credentials")),
219
+ "requests": a.get("requests", 0), "params": params.get(n["id"], []),
220
+ "referenced_by": uniq(refs_in.get(n["id"], [])),
221
+ "called_from": uniq(calls_in.get(n["id"], [])),
222
+ "privileged": bool(PRIV_RE.search(a.get("path", "") or "")),
223
+ "telemetry": bool(TELEMETRY_RE.search(a.get("path", "") or "")),
224
+ "evidence": n["evidence"],
225
+ })
226
+
227
+ routes = [{
228
+ "id": n["id"], "label": n["label"], "host": n["attrs"].get("host"),
229
+ "statuses": n["attrs"].get("statuses") or [], "roles": n["roles"],
230
+ "calls": uniq(route_calls.get(n["id"], [])),
231
+ "scripts": uniq(route_scripts.get(n["id"], [])), "evidence": n["evidence"],
232
+ } for n in data["nodes"] if n["type"] == "route"]
233
+
234
+ scripts = [{
235
+ "id": n["id"], "label": n["label"], "host": n["attrs"].get("host"),
236
+ "scope": n["attrs"].get("scope"), "references": uniq(script_refs.get(n["id"], [])),
237
+ "evidence": n["evidence"],
238
+ } for n in data["nodes"] if n["type"] == "script"]
239
+
240
+ third_parties = [{
241
+ "id": n["id"], "label": n["label"], "requests": n["attrs"].get("requests", 0),
242
+ "referenced_by": uniq(tp_refs.get(n["id"], [])), "evidence": n["evidence"],
243
+ } for n in data["nodes"] if n["type"] == "third_party"]
244
+
245
+ auth = [{"id": n["id"], "label": n["label"],
246
+ "endpoints": uniq(auth_eps.get(n["id"], []))}
247
+ for n in data["nodes"] if n["type"] == "auth"]
248
+ cookies = [{"name": n["label"], "httponly": n["attrs"].get("httponly"),
249
+ "secure": n["attrs"].get("secure"), "samesite": n["attrs"].get("samesite"),
250
+ "evidence": n["evidence"]}
251
+ for n in data["nodes"] if n["type"] == "cookie"]
252
+ operations = [{"label": n["label"], "evidence": n["evidence"], "roles": n["roles"]}
253
+ for n in data["nodes"] if n["type"] == "operation"]
254
+ hosts = [{"label": n["label"], "schemes": n["attrs"].get("schemes"),
255
+ "ports": n["attrs"].get("ports"), "tech": n["attrs"].get("tech") or {},
256
+ "evidence": n["evidence"]}
257
+ for n in data["nodes"] if n["type"] == "host"]
258
+
259
+ unknowns = [{"type": u["type"],
260
+ "entity": nodes[u["entity"]]["label"] if u["entity"] in nodes
261
+ else u["entity"].split(":")[-1],
262
+ "we_know": u["we_know"], "we_dont_know": u["we_dont_know"],
263
+ "next": u["next_step"]} for u in data["unknowns"]]
264
+
265
+ graph = {
266
+ "nodes": [{"id": n["id"], "label": n["label"], "type": n["type"],
267
+ "layer": n["layer"], "state": n["attrs"].get("api_state"),
268
+ "evidence": n["evidence"][:6]} for n in data["nodes"]],
269
+ "edges": [{"s": e["src"], "d": e["dst"], "type": e["type"], "state": e["state"]}
270
+ for e in data["edges"]],
271
+ }
272
+ # primary host = the busiest first-party host, not merely the first one
273
+ primary = max(hosts, key=lambda h: len(h.get("evidence") or []), default=None)
274
+ primary = primary["label"] if primary else None
275
+ node_ids = {n["id"] for n in graph["nodes"]}
276
+ o_nodes, o_edges, o_summary = _osint_graph(osint, primary, node_ids)
277
+ graph["nodes"].extend(o_nodes)
278
+ graph["edges"].extend(o_edges)
279
+
280
+ return {
281
+ "tool": "proxy2model", "version": __version__, "schema": SCHEMA_VERSION,
282
+ "generated": (time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime(int(os.environ["SOURCE_DATE_EPOCH"])))
283
+ if os.environ.get("SOURCE_DATE_EPOCH", "").isdigit()
284
+ else time.strftime("%Y-%m-%d %H:%M %Z")),
285
+ "app": data["app"], "scope": data["scope"], "roles": data["roles"],
286
+ "counts": data["counts"], "stats": data.get("stats", {}),
287
+ "hosts": hosts, "routes": routes, "scripts": scripts, "endpoints": endpoints,
288
+ "operations": operations, "third_parties": third_parties, "auth": auth,
289
+ "cookies": cookies, "secrets": data["secrets"], "unknowns": unknowns,
290
+ "graph": graph,
291
+ "stack": m.stack,
292
+ "context": context_package(m, lens=lens, osint=osint),
293
+ "methodology": {"prompt": METHODOLOGY_PROMPT, "scaffold": investigation_plan(m)},
294
+ "osint": o_summary,
295
+ "evidence": _EVIDENCE if lazy_evidence else {f"ev_{e['id']}": e for e in data.get("evidence", [])},
296
+ }
297
+
298
+
299
+ def _seen(conn, endpoints: list) -> dict:
300
+ """When each endpoint first and last appeared across the builds kept in graph.db (only with 2+ builds)."""
301
+ from ..db import store
302
+ builds = store.list_builds(conn)
303
+ if len(builds) < 2:
304
+ return {}
305
+ seen = {}
306
+ for e in endpoints:
307
+ h = store.history(conn, "node", e["id"])
308
+ if h:
309
+ seen[e["id"]] = [h["first_build"], h["last_build"]]
310
+ return {"seen": seen, "builds": [[b["id"], b["built_at"][:10], b["label"]] for b in builds]}
311
+
312
+
313
+ def write_html_report(m: Model, path: str, osint: dict | None = None,
314
+ lens: str = "attention", db_path: str | None = None,
315
+ screenshot: tuple[str, bytes] | None = None,
316
+ changes: dict | None = None) -> None:
317
+ payload = build_payload(m, osint=osint, lens=lens, lazy_evidence=True)
318
+ if changes:
319
+ payload["changes"] = changes
320
+ if screenshot:
321
+ from . import shot
322
+ payload["screenshot"] = shot.data_uri(*screenshot)
323
+ if db_path:
324
+ # the in-page query console reads the same graph.db the CLI queries
325
+ from ..db import store
326
+ conn = store.open_db(db_path)
327
+ try:
328
+ payload["bql"] = store.export_for_report(conn)
329
+ payload.update(_seen(conn, payload["endpoints"]))
330
+ finally:
331
+ conn.close()
332
+ html = (_TEMPLATE.replace("__BQLJS__", BQL_JS)
333
+ .replace("__DASHCSS__", DASH_CSS).replace("__DASHJS__", DASH_JS)
334
+ .replace("__RRCSS__", RR_CSS).replace("__PGCSS__", PG_CSS).replace("__PGJS__", PG_JS).replace("__RRJS__", RR_JS).replace("__INVCSS__", INV_CSS).replace("__INVJS__", INV_JS)
335
+ .replace("__QRYCSS__", QRY_CSS).replace("__QRYJS__", QRY_JS)
336
+ .replace("__MAPCSS__", MAP_CSS).replace("__TYPECSS__", TYPE_CSS).replace("__FONTCSS__", font_css()).replace("__MAPJS__", MAP_JS))
337
+ head, _, tail = html.partition("__DATA__")
338
+ # stream the data in pieces: a large capture never exists as one more full-size string
339
+ with atomic_write(path) as f:
340
+ f.write(head)
341
+ marker = json.dumps(_EVIDENCE)
342
+ for piece in json.JSONEncoder(ensure_ascii=False).iterencode(payload):
343
+ if piece.endswith(marker): # the key and ": " ride along in the same piece
344
+ f.write(_escape_script(piece[:-len(marker)] + "{"))
345
+ for i, e in enumerate(m.evidence_log):
346
+ f.write(_escape_script(("" if not i else ", ") + json.dumps(f"ev_{e['id']}") + ": "
347
+ + json.dumps(e, ensure_ascii=False)))
348
+ f.write(_escape_script("}"))
349
+ else:
350
+ f.write(_escape_script(piece))
351
+ f.write(tail)
352
+
353
+
354
+ _TEMPLATE = r"""<!doctype html>
355
+ <html lang="en" data-theme="dark">
356
+ <head>
357
+ <meta charset="utf-8">
358
+ <meta name="viewport" content="width=device-width, initial-scale=1">
359
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:; font-src data:">
360
+ <meta name="color-scheme" content="light dark">
361
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Crect x='8' y='8' width='496' height='496' rx='88' fill='%231c0f07' stroke='%236a2805' stroke-width='16'/%3E%3Cpath d='M37 39.5 L72 39.5 L26.5 66.5 Q43 51 46 49 Q39 45 37 39.5 Z' transform='translate(-25.4 -50.1) scale(5.75)' fill='%23f16001'/%3E%3C/svg%3E">
362
+ <title>proxy2model report</title>
363
+ <style>
364
+ __FONTCSS__
365
+ :root{
366
+ /* light: the field-notes paper. dark (the default) is the same ink as the README art */
367
+ --ink:#f6f1e9;--panel:#fbf8f2;--panel2:#f2ece2;--panel3:#f7f2ea;--line:#e2d9ca;--line2:#d3c7b4;
368
+ --text:#0b0a08;--muted:#5f584e;--faint:#8a8174;--signal:#d94a00;--signal-soft:#fbe6d8;
369
+ --observed:#2f6f93;--inferred:#6b4fb3;--ok:#4f7d3a;--warn:#a8700a;--bad:#c23b2b;--hot:#d94a00;
370
+ --l1:#8a8174;--l2:#4f7d3a;--l3:#6b4fb3;--l4:#2f6f93;--l5:#3a342c;--l6:#d94a00;
371
+ --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
372
+ --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
373
+ --shadow:0 8px 30px -12px rgba(20,25,36,.22);--radius:12px;
374
+ }
375
+ html[data-theme="dark"]{
376
+ --ink:#0b0a08;--panel:#100e0b;--panel2:#17140f;--panel3:#0e0c0a;--line:#24201a;--line2:#332d25;
377
+ --text:#f3ece0;--muted:#a39a8d;--faint:#6f6a61;--signal:#e85002;--signal-soft:#2a1406;
378
+ --observed:#79aecb;--inferred:#b49be0;--ok:#8fbf7a;--warn:#d9a441;--bad:#e5604f;--hot:#e85002;
379
+ --l1:#8a8174;--l2:#8fbf7a;--l3:#b49be0;--l4:#79aecb;--l5:#ded2c0;--l6:#e85002;
380
+ --shadow:0 12px 40px -16px rgba(0,0,0,.7);
381
+ }
382
+ *{box-sizing:border-box}
383
+ html,body{margin:0;height:100%}
384
+ body{background:var(--ink);color:var(--text);font:14px/1.55 var(--sans);-webkit-font-smoothing:antialiased}
385
+ a{color:var(--signal);text-decoration:none}
386
+ .mono{font-family:var(--mono)}
387
+ button{font:inherit;cursor:pointer;color:inherit}
388
+ ::selection{background:var(--signal);color:#fff}
389
+ :focus-visible{outline:2px solid var(--signal);outline-offset:2px;border-radius:6px}
390
+
391
+ /* header */
392
+ header{position:sticky;top:0;z-index:40;display:flex;align-items:center;gap:16px;height:58px;
393
+ padding:0 18px;background:var(--panel);border-bottom:1px solid var(--line)}
394
+ .logo{display:flex;align-items:center;gap:9px;font:700 15px var(--mono);letter-spacing:-.02em;white-space:nowrap}
395
+ .logo b{color:var(--signal)}
396
+ .logo svg{width:26px;height:26px}
397
+ .crumbs{color:var(--muted);font:12px var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
398
+ .crumbs b{color:var(--text)}
399
+ .search{margin-left:auto;position:relative;flex:0 1 340px}
400
+ .search input{width:100%;height:36px;padding:0 12px 0 34px;border:1px solid var(--line2);border-radius:9px;
401
+ background:var(--panel2);color:var(--text);font:13px var(--sans)}
402
+ .search svg{position:absolute;left:10px;top:9px;width:16px;height:16px;color:var(--faint)}
403
+ .hbtn{display:inline-grid;place-items:center;width:34px;height:34px;border:1px solid var(--line);border-radius:8px;background:var(--panel2)}
404
+ .hbtn:hover{border-color:var(--line2)}
405
+ .hbtn svg{width:16px;height:16px}
406
+ .copyai{position:relative;display:flex}
407
+ .btnai{display:inline-flex;align-items:center;gap:7px;height:34px;padding:0 12px;border:1px solid var(--signal);border-radius:8px 0 0 8px;background:var(--signal);color:#fff;font:600 13px var(--sans);white-space:nowrap}
408
+ .btnai:hover{filter:brightness(1.06)}
409
+ .btnai svg{width:15px;height:15px}
410
+ .btnai .tok{font:600 10px var(--mono);opacity:.85;padding-left:4px;border-left:1px solid rgba(255,255,255,.35);margin-left:2px}
411
+ .btnai.split{padding:0 8px;border-left:1px solid rgba(255,255,255,.35);border-radius:0 8px 8px 0;font-size:11px}
412
+ .btnai.done{background:var(--ok);border-color:var(--ok)}
413
+ .menu{position:absolute;top:40px;right:0;min-width:240px;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:var(--shadow);padding:6px;z-index:60}
414
+ .menu button{display:flex;flex-direction:column;gap:2px;width:100%;text-align:left;padding:8px 10px;border:0;border-radius:7px;background:none;color:var(--text)}
415
+ .menu button:hover{background:var(--panel2)}
416
+ .menu button b{font:600 13px var(--sans)}
417
+ .menu button span{color:var(--muted);font-size:11.5px}
418
+ .menu button .tk{color:var(--faint);font:600 10px var(--mono)}
419
+ .htheme .sun{display:none}html[data-theme="dark"] .htheme .sun{display:block}html[data-theme="dark"] .htheme .moon{display:none}
420
+
421
+ /* shell */
422
+ .shell{display:grid;grid-template-columns:212px minmax(0,1fr);height:calc(100% - 58px)}
423
+ nav.views{border-right:1px solid var(--line);background:var(--panel);padding:14px 10px;overflow:auto}
424
+ nav.views h3{font:600 10px var(--mono);letter-spacing:.13em;text-transform:uppercase;color:var(--faint);margin:14px 8px 6px}
425
+ nav.views h3:first-child{margin-top:0}
426
+ .vbtn{display:flex;align-items:center;gap:9px;width:100%;padding:8px 10px;border:0;border-radius:8px;background:none;
427
+ color:var(--muted);font:500 13.5px var(--sans);text-align:left;margin-bottom:1px}
428
+ .vbtn:hover{background:var(--panel2);color:var(--text)}
429
+ .vbtn.on{background:var(--signal-soft);color:var(--signal);font-weight:600}
430
+ .vbtn .ct{margin-left:auto;font:600 11px var(--mono);color:var(--faint);background:var(--panel2);padding:1px 7px;border-radius:999px}
431
+ .vbtn.on .ct{color:var(--signal);background:#fff}
432
+ html[data-theme="dark"] .vbtn.on .ct{background:var(--panel3)}
433
+ .vbtn .dot{width:8px;height:8px;border-radius:2px;flex:none}
434
+
435
+ main{overflow:auto;padding:26px 32px 60px;min-width:0}
436
+ .view{display:none}
437
+ .view.on{display:block;animation:fade .2s ease}
438
+ @keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1}}
439
+ h1.vt{font:700 24px/1.2 var(--sans);letter-spacing:-.02em;margin:0 0 4px}
440
+ .vsub{color:var(--muted);margin:0 0 20px;max-width:80ch}
441
+
442
+ /* kpis */
443
+ .kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:22px}
444
+ .kpi{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:14px 16px}
445
+ .kpi .n{font:700 28px var(--sans);letter-spacing:-.02em;line-height:1}
446
+ .kpi .k{color:var(--muted);font-size:12px;margin-top:5px}
447
+ .kpi.sig .n{color:var(--signal)}
448
+ .kpi.ok .n{color:var(--ok)}
449
+
450
+ /* cards + tables */
451
+ .card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:18px;overflow:hidden}
452
+ .card>.hd{display:flex;align-items:center;gap:10px;padding:13px 16px;border-bottom:1px solid var(--line);font:600 13px var(--sans)}
453
+ .card>.hd .ct{color:var(--faint);font:600 11px var(--mono)}
454
+ .card>.bd{padding:6px 0}
455
+ table{width:100%;border-collapse:collapse;font-size:13px}
456
+ th{position:sticky;top:0;text-align:left;font:600 11px var(--mono);letter-spacing:.04em;text-transform:uppercase;
457
+ color:var(--muted);background:var(--panel2);padding:9px 14px;border-bottom:1px solid var(--line);cursor:pointer;white-space:nowrap}
458
+ th .ar{opacity:.4;font-size:9px}
459
+ td{padding:9px 14px;border-bottom:1px solid var(--line);vertical-align:middle}
460
+ tr:last-child td{border-bottom:0}
461
+ tbody tr{cursor:pointer}
462
+ tbody tr:hover{background:var(--panel2)}
463
+ .trunc{max-width:420px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
464
+ .empty{padding:26px 16px;color:var(--faint);text-align:center;font-size:13px}
465
+
466
+ /* pills */
467
+ .pill{display:inline-flex;align-items:center;gap:5px;font:600 11px var(--mono);padding:2px 8px;border-radius:999px;
468
+ border:1px solid currentColor;white-space:nowrap}
469
+ .p-both{color:var(--ok)}.p-static{color:var(--inferred)}.p-runtime{color:var(--observed)}
470
+ .p-get{color:var(--muted)}.p-post,.p-put,.p-patch,.p-delete{color:var(--signal)}
471
+ .m{font:600 11px var(--mono);padding:2px 7px;border-radius:6px;background:var(--panel2);color:var(--muted)}
472
+ .m.w{color:var(--signal);background:var(--signal-soft)}
473
+ .m.any{color:var(--faint);background:none;border:1px dashed var(--line2);cursor:help}
474
+ .st{font:600 11px var(--mono);padding:1px 6px;border-radius:5px;background:var(--panel2)}
475
+ .st.g{color:var(--ok)}.st.r{color:var(--bad)}.st.y{color:var(--warn)}
476
+ .chipwrap{display:flex;flex-wrap:wrap;gap:6px}
477
+ .chip{display:inline-flex;align-items:center;gap:6px;font:500 12px var(--sans);padding:5px 11px;border-radius:999px;
478
+ border:1px solid var(--line2);background:var(--panel);color:var(--muted)}
479
+ .chip:hover{border-color:var(--faint);color:var(--text)}
480
+ .chip.on{background:var(--text);color:var(--ink);border-color:var(--text)}
481
+ .chip .c{font:600 11px var(--mono);opacity:.7}
482
+ .filters{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-start;margin-bottom:16px}
483
+ .fg{display:flex;flex-direction:column;gap:6px}
484
+ .fg>.lab{font:600 10px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--faint)}
485
+
486
+ /* priorities */
487
+ .prio{display:flex;gap:14px;padding:14px 16px;border:1px solid var(--line);border-left:3px solid var(--sev);border-radius:10px;
488
+ background:var(--panel);margin-bottom:10px;cursor:pointer}
489
+ .prio:hover{border-color:var(--line2)}
490
+ .prio .rank{font:700 13px var(--mono);color:var(--sev);flex:none;width:22px}
491
+ .prio .body{min-width:0;flex:1}
492
+ .prio .ttl{font:600 14px var(--sans);display:flex;align-items:center;gap:8px;flex-wrap:wrap}
493
+ .prio .why{color:var(--muted);font-size:13px;margin-top:3px}
494
+ .prio .nx{color:var(--text);font-size:12.5px;margin-top:6px}
495
+ .prio .nx b{color:var(--signal);font:600 10px var(--mono);letter-spacing:.06em;text-transform:uppercase;margin-right:6px}
496
+ .sev-hot{--sev:var(--signal)}.sev-warn{--sev:var(--warn)}.sev-info{--sev:var(--observed)}
497
+
498
+
499
+ /* query console */
500
+ .tag-ext{font:600 10px var(--mono);letter-spacing:.05em;color:var(--inferred);border:1px solid var(--inferred);border-radius:5px;padding:0 5px}
501
+ .icard{margin-bottom:14px}
502
+ .icard .bd{padding:0}
503
+ .icard td.k{width:190px;color:var(--muted);font:12px var(--mono)}
504
+ .icard td.v{font:12.5px var(--mono);overflow-wrap:anywhere}
505
+ .miss{color:var(--warn)}
506
+
507
+ /* unknowns list */
508
+ .unk{padding:14px 16px;border:1px solid var(--line);border-radius:10px;background:var(--panel);margin-bottom:10px}
509
+ .unk .ut{font:600 12px var(--mono);color:var(--signal);letter-spacing:.03em}
510
+ .unk .ue{font:600 14px var(--sans);margin:4px 0}
511
+ .unk ul{margin:6px 0 0;padding-left:18px;color:var(--muted);font-size:13px}
512
+ .unk .nx{margin-top:8px;font-size:12.5px}.unk .nx b{color:var(--signal);font:600 10px var(--mono);text-transform:uppercase;margin-right:6px}
513
+
514
+ /* matrix */
515
+ .matrix td,.matrix th{text-align:center}
516
+ .matrix td:first-child,.matrix th:first-child{text-align:left}
517
+ .cell{display:inline-block;min-width:42px;font:600 11px var(--mono);padding:2px 6px;border-radius:5px}
518
+ .cell.hit{background:var(--signal-soft);color:var(--signal)}
519
+ .cell.miss{color:var(--faint)}
520
+
521
+ /* ask (AI-off query) */
522
+ .askbar{position:relative;margin-bottom:14px}
523
+ .askbar input{width:100%;height:52px;padding:0 18px 0 46px;border:1px solid var(--line2);border-radius:12px;background:var(--panel);color:var(--text);font:16px var(--sans)}
524
+ .askbar input:focus{border-color:var(--signal);outline:none}
525
+ .askbar svg{position:absolute;left:16px;top:17px;width:18px;height:18px;color:var(--faint)}
526
+ .presets{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:20px}
527
+ .preset{font:500 13px var(--sans);padding:7px 13px;border-radius:999px;border:1px solid var(--line2);background:var(--panel);color:var(--muted)}
528
+ .preset:hover{border-color:var(--faint);color:var(--text)}
529
+ .answer .ahd{font:600 15px var(--sans);margin:0 0 12px}
530
+ .answer .arow{display:flex;align-items:center;gap:10px;padding:10px 14px;border:1px solid var(--line);border-radius:9px;margin-bottom:7px;cursor:pointer}
531
+ .answer .arow:hover{border-color:var(--line2);background:var(--panel2)}
532
+ .answer .arow .p{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
533
+ .answer .arow .ev{font:600 11px var(--mono);color:var(--faint)}
534
+ .answer .note{color:var(--muted);font-size:14px;margin:0 0 14px;max-width:70ch}
535
+ .askfoot{display:inline-flex;align-items:center;gap:8px;margin-top:16px;padding:7px 12px;border-radius:999px;background:var(--panel2);border:1px solid var(--line);color:var(--muted);font:500 12.5px var(--sans)}
536
+ .askfoot::before{content:"";width:8px;height:8px;border-radius:50%;background:var(--ok)}
537
+
538
+ /* drawer */
539
+ .scrim{position:fixed;inset:0;background:rgba(20,25,36,.35);opacity:0;visibility:hidden;transition:.18s;z-index:50}
540
+ .scrim.on{opacity:1;visibility:visible}
541
+ .drawer{position:fixed;top:0;right:0;height:100%;width:min(720px,94vw);background:var(--panel);border-left:1px solid var(--line);
542
+ box-shadow:var(--shadow);transform:translateX(100%);transition:transform .22s cubic-bezier(.4,0,.2,1);z-index:51;display:flex;flex-direction:column}
543
+ .drawer.on{transform:none}
544
+ .drawer .dh{display:flex;align-items:center;gap:10px;padding:16px 18px;border-bottom:1px solid var(--line)}
545
+ .drawer .dh .x{margin-left:auto;width:30px;height:30px;border:1px solid var(--line);border-radius:7px;background:var(--panel2)}
546
+ .drawer .db{overflow:auto;padding:18px}
547
+ .drv{height:560px;border:1px solid var(--line);border-radius:10px;overflow:hidden}
548
+ .drawer h4{font:600 10px var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--faint);margin:20px 0 8px}
549
+ .drawer h4:first-child{margin-top:0}
550
+ .kv{display:grid;grid-template-columns:120px 1fr;gap:6px 12px;font-size:13px}
551
+ .kv .k{color:var(--muted)}
552
+ .kv .v{word-break:break-word}
553
+ .taglist{display:flex;flex-wrap:wrap;gap:6px}
554
+ .taglist span{font:500 12px var(--mono);padding:3px 8px;border-radius:6px;background:var(--panel2);color:var(--text)}
555
+ .foot{color:var(--faint);font-size:12px;border-top:1px solid var(--line);padding:16px 0 0;margin-top:24px}
556
+ .callout{display:flex;gap:12px;padding:13px 15px;border:1px solid var(--line);border-radius:10px;background:var(--panel2);margin-bottom:18px}
557
+ .callout .i{color:var(--signal);font:700 13px var(--mono);flex:none}
558
+ .callout p{margin:0;color:var(--muted);font-size:13px}
559
+ .hide{display:none!important}
560
+
561
+ /* stacked distribution bars (overview) */
562
+ .segbar{display:flex;height:10px;border-radius:999px;overflow:hidden;background:var(--panel2);margin:10px 0 8px}
563
+ .segbar span{display:block;height:100%}
564
+ .seglegend{display:flex;flex-wrap:wrap;gap:14px;font-size:12px;color:var(--muted)}
565
+ .seglegend .k{display:inline-flex;align-items:center;gap:6px}
566
+ .seglegend .sw{width:9px;height:9px;border-radius:3px}
567
+ .seglegend b{color:var(--text);font:600 12px var(--mono)}
568
+
569
+ /* grouped priority signals */
570
+ .prio .sig{display:flex;gap:8px;color:var(--muted);font-size:13px;margin-top:5px;align-items:baseline}
571
+ .prio .sig::before{content:"›";color:var(--sev);font-weight:700;flex:none}
572
+ .prio .sig b{color:var(--text);font-weight:600}
573
+
574
+ __DASHCSS__
575
+ __RRCSS__
576
+ __PGCSS__
577
+ __INVCSS__
578
+ __QRYCSS__
579
+ __MAPCSS__
580
+ __TYPECSS__
581
+ @media(max-width:820px){
582
+ .shell{display:flex;flex-direction:column;height:auto}
583
+ nav.views{display:flex;gap:2px;overflow-x:auto;border-right:0;border-bottom:1px solid var(--line);padding:8px 10px;white-space:nowrap}
584
+ nav.views h3{display:none}
585
+ .vbtn{width:auto;flex:none}
586
+ main{padding:16px}.search{flex-basis:150px}.crumbs{display:none}
587
+ body{height:auto}html{height:auto}
588
+ }
589
+ @media print{header,nav.views,.search,.hbtn,.scrim,.drawer{display:none!important}.shell{display:block}.view{display:block!important;page-break-after:always}main{overflow:visible}}
590
+ </style>
591
+ </head>
592
+ <body>
593
+ <header>
594
+ <div class="logo"><svg viewBox="0 0 512 512" aria-hidden="true"><rect x="8" y="8" width="496" height="496" rx="88" fill="#1c0f07" stroke="#6a2805" stroke-width="16"/><path d="M37 39.5 L72 39.5 L26.5 66.5 Q43 51 46 49 Q39 45 37 39.5 Z" transform="translate(-25.4 -50.1) scale(5.75)" fill="#f16001"/></svg><span>proxy<b>2</b>model</span></div>
595
+ <div class="crumbs" id="crumbs"></div>
596
+ <div class="search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
597
+ <input id="q" type="search" placeholder="Search endpoints, hosts, params, evidence…" aria-label="Search"></div>
598
+ <div class="copyai">
599
+ <button class="btnai" id="copyai"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg><span>Copy for AI</span><span class="tok" id="copytok"></span></button>
600
+ <button class="btnai split" id="copymenu" aria-label="Copy scope" aria-haspopup="true">▾</button>
601
+ <div class="menu" id="copyopts" role="menu" hidden></div>
602
+ </div>
603
+ <button class="hbtn htheme" id="theme" title="Toggle theme" aria-label="Toggle theme"><svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg></button>
604
+ <button class="hbtn" id="print" title="Print / PDF" aria-label="Print"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9V3h12v6M6 18H4a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2h-2M6 14h12v7H6z"/></svg></button>
605
+ <button class="hbtn" id="export" title="Export model.json" aria-label="Export JSON"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12M7 10l5 5 5-5M5 21h14"/></svg></button>
606
+ </header>
607
+ <div class="shell">
608
+ <nav class="views" id="nav"></nav>
609
+ <main id="main"></main>
610
+ </div>
611
+ <div class="scrim" id="scrim"></div>
612
+ <aside class="drawer" id="drawer" aria-hidden="true"><div class="dh"><span id="dtitle" style="font-weight:600"></span><button class="x" id="dclose" aria-label="Close">✕</button></div><div class="db" id="dbody"></div></aside>
613
+
614
+ <script>
615
+ const D = __DATA__;
616
+ __BQLJS__
617
+ const BQLDB=D.bql?Object.assign({},D.bql,{requests:BQL.requestsFrom(D.evidence)}):null;
618
+ const $=(s,r)=>(r||document).querySelector(s), $$=(s,r)=>[...(r||document).querySelectorAll(s)];
619
+ const esc=s=>String(s==null?"":s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
620
+ const PRIV=/\/(admin|administrator|internal|manage|management|config|privileged|superuser|staff|root|debug|actuator|console|backoffice|ops)(\/|$)/i;
621
+ /* turn the model's ALL_CAPS_TOKENS into readable text, keeping known acronyms.
622
+ Only ever applied to the tool's own vocabulary — never to paths/hosts/params. */
623
+ const ACR=new Set(["API","URL","URI","TLS","SSL","DNS","JWT","OTP","HTTP","HTTPS","CORS","SPF","DKIM","DMARC","ID","JS","HTML","CSS","IP","RDAP","SAN","CDN","XSS","CSRF","SSO","MFA","2FA","PII","GET","POST","PUT","PATCH","DELETE","HEAD"]);
624
+ function humanize(t){if(t==null)return"";return String(t).split(/[_\s]+/).map((w,i)=>{const u=w.toUpperCase();if(ACR.has(u))return u;const l=w.toLowerCase();return i===0?l.charAt(0).toUpperCase()+l.slice(1):l;}).join(" ");}
625
+ const c=D.counts, EP=D.endpoints;
626
+ const EVD=i=>"EVD "+i;
627
+ document.title=(D.app?D.app+" · ":"")+"proxy2model report";
628
+
629
+ /* theme (persist per-viewer, fail-safe) */
630
+ try{const t=localStorage.getItem("b2m-report-theme");if(t)document.documentElement.setAttribute("data-theme",t);}catch(e){}
631
+ $("#theme").onclick=()=>{const n=document.documentElement.getAttribute("data-theme")==="dark"?"light":"dark";document.documentElement.setAttribute("data-theme",n);try{localStorage.setItem("b2m-report-theme",n)}catch(e){}};
632
+ $("#print").onclick=()=>window.print();
633
+ $("#export").onclick=()=>{const b=new Blob([JSON.stringify(D,null,2)],{type:"application/json"});const u=URL.createObjectURL(b);const a=document.createElement("a");a.href=u;a.download=(D.app||"model")+".proxy2model.json";a.click();URL.revokeObjectURL(u);};
634
+ $("#crumbs").innerHTML=`<b>${esc(D.app)}</b> · scope ${esc((D.scope||[]).join(", ")||"—")} · ${esc(D.version)}`;
635
+
636
+ /* ---------- Copy for AI (hands the context to the user's own model) ---------- */
637
+ const CTX=D.context||{};
638
+ const PROMPT=[
639
+ "You are a senior application security researcher. Below is an evidence-backed model of a single web application, built from traffic that was actually captured. It is structure, not raw bytes. Reason only from it.",
640
+ "",
641
+ "The model includes a `graph`: an `adjacency` map (each node id -> the nodes it points to, with the edge type and whether it was OBSERVED in traffic or INFERRED from code), plus derived reasoning views — `communities` (functional areas), `execution_flows` (how each endpoint is reached from an entry point), `trust_zones` (how identity reaches the surface), `hubs` and `coupling`. Walk the graph to understand how the app fits together before you judge any one part.",
642
+ "",
643
+ "Ground rules:",
644
+ "- Cite the evidence id(s) — ev_N — for every claim. The `evidence` map resolves each one to a real request.",
645
+ "- Never name an endpoint, parameter, route, host or cookie that is not in the model, and never assert an edge the graph does not contain.",
646
+ "- Everything you produce is a hypothesis to verify safely, never a finding, an exploit or a payload. The goal is to understand the app deeply, not to attack it.",
647
+ "- An absence in the model is an absence in the capture, not proof of absence in the target. `inferred` means found in code, not confirmed called.",
648
+ "",
649
+ "Do this:",
650
+ "",
651
+ "1. ATTACK SURFACE — group the endpoints by function (authentication, payments / gift cards, account, admin, catalog, config, etc.). For each endpoint give: method + path, its parameters, whether a credential was seen on it, the statuses observed, and which host / trust boundary it sits behind. Flag anything reached without a credential and anything on a privileged-looking path.",
652
+ "",
653
+ "2. WHERE TO HUNT — a prioritised list, highest value first, of what to test and why. For each item name the endpoint or parameter, the evidence that makes it interesting, the specific issue class worth checking (BOLA / IDOR, broken auth or missing authorization, injection, SSRF, business-logic / price or balance tampering, secrets, mass assignment), and the single next request or two-account comparison that would confirm or kill it. Prefer what the evidence supports over generic OWASP advice. Give me the shortlist I should spend my time on.",
654
+ "",
655
+ "3. OPEN QUESTIONS — restate each unknown the model lists and the exact capture or request needed to close it.",
656
+ "",
657
+ "4. WHAT STAYS UNCERTAIN — and why this capture cannot answer it.",
658
+ ].join("\n");
659
+ const METH=D.methodology||{};
660
+ function buildCopy(scope){
661
+ let obj, note;
662
+ if(scope==="methodology"){
663
+ // a distinct prompt: turn the model + scaffold into a target-specific methodology
664
+ const obj={app:CTX.app,scope:CTX.scope,shape:CTX.shape,plan_scaffold:METH.scaffold,
665
+ graph:CTX.graph,endpoints:CTX.endpoints,unknowns:CTX.unknowns,evidence:CTX.evidence};
666
+ return [(METH.prompt||[]).join("\n"),"","MODEL AND SCAFFOLD (JSON):","```json",JSON.stringify(obj,null,2),"```"].join("\n");
667
+ }
668
+ if(scope==="endpoints"){obj={app:CTX.app,scope:CTX.scope,endpoints:CTX.endpoints,evidence:CTX.evidence};note="endpoints and evidence only";}
669
+ else if(scope==="open"){obj={app:CTX.app,unknowns:CTX.unknowns,endpoints:CTX.endpoints,evidence:CTX.evidence};note="open questions, with endpoints for context";}
670
+ else if(scope==="graph"){obj={app:CTX.app,scope:CTX.scope,shape:CTX.shape,graph:CTX.graph,evidence:CTX.evidence};note="reasoning graph — adjacency and derived views";}
671
+ else{obj=CTX;note="whole model";}
672
+ return [PROMPT,"",`MODEL (${note}, JSON):`,"```json",JSON.stringify(obj,null,2),"```"].join("\n");
673
+ }
674
+ function doCopy(scope,btn){
675
+ const text=buildCopy(scope);
676
+ const done=()=>{const b=$("#copyai");b.classList.add("done");$("#copyai span").textContent="Copied";setTimeout(()=>{b.classList.remove("done");$("#copyai span").textContent="Copy for AI";},1500);$("#copyopts").hidden=true;};
677
+ if(navigator.clipboard&&navigator.clipboard.writeText)navigator.clipboard.writeText(text).then(done,()=>fallbackCopy(text,done));
678
+ else fallbackCopy(text,done);
679
+ }
680
+ function fallbackCopy(text,done){const ta=document.createElement("textarea");ta.value=text;ta.style.position="fixed";ta.style.opacity="0";document.body.append(ta);ta.select();try{document.execCommand("copy");done();}catch(e){}ta.remove();}
681
+ $("#copytok").textContent="~"+Math.max(1,Math.round((CTX.token_estimate||0)/1000))+"k tok";
682
+ $("#copyai").onclick=()=>doCopy("whole");
683
+ const OPTS=[["methodology","Hunting methodology","have the AI reason a recon & hunting plan for THIS app, not a checklist",null],
684
+ ["whole","Whole model","everything an AI needs, with the rules",CTX.token_estimate],
685
+ ["graph","Reasoning graph","adjacency + communities, flows, trust zones, for the AI to traverse",null],
686
+ ["endpoints","Endpoints only","the API surface with parameters and evidence",null],
687
+ ["open","Open questions only","what to test next, for a focused prompt",null]];
688
+ $("#copyopts").innerHTML=OPTS.map(([k,t,d,tok])=>`<button role="menuitem" data-scope="${k}"><b>${esc(t)}</b><span>${esc(d)}${tok?` · ~${Math.round(tok/1000)}k tok`:""}</span></button>`).join("");
689
+ $("#copymenu").onclick=e=>{e.stopPropagation();$("#copyopts").hidden=!$("#copyopts").hidden;};
690
+ $$("#copyopts button").forEach(b=>b.onclick=()=>doCopy(b.dataset.scope));
691
+ document.addEventListener("click",e=>{if(!e.target.closest(".copyai"))$("#copyopts").hidden=true;});
692
+
693
+ /* ---------- priority scoring: "the important stuff" ----------
694
+ One card per endpoint. Each matching signal is listed on the card; the
695
+ card's severity is the strongest signal's. */
696
+ const SEV_RANK={hot:0,warn:1,info:2};
697
+ const STATIC_N=EP.filter(e=>e.state==="STATIC_ONLY").length;
698
+ function signalsFor(e){
699
+ const sig=[];
700
+ if(e.telemetry&&!e.privileged)return sig; /* a beacon is traffic, not a lead */
701
+ /* a few code-only paths are leads; dozens are just what a big bundle names, so they do not crowd out the rest */
702
+ if(e.state==="STATIC_ONLY") sig.push({sev:STATIC_N>12&&!e.privileged?"info":"hot",ttl:"Referenced in code, never called",
703
+ why:"The client code names this endpoint but no request to it was captured, so nobody has walked this path.",
704
+ next:"Request it directly in an authorised session and observe the response."});
705
+ if(e.privileged&&e.requests>0) sig.push({sev:"hot",ttl:"Privileged-looking path was reached",
706
+ why:"The path matches an admin/internal pattern and appears in the capture"+(e.roles.length?` (roles: ${e.roles.join(", ")})`:"")+".",
707
+ next:"Confirm the response was meant for the caller; replay across accounts."});
708
+ else if(e.privileged) sig.push({sev:"hot",ttl:"Privileged-looking path referenced",
709
+ why:"The path matches an admin/internal pattern; it was only referenced in code, never requested.",
710
+ next:"Request it in an authorised session and observe who is allowed in."});
711
+ const ss=e.statuses||[];
712
+ if(ss.length&&ss.every(s=>s>=400)) sig.push({sev:"warn",ttl:"Only ever returned errors",
713
+ why:`Every observed status was ${ss.join(", ")} so behaviour with valid input or auth has not been seen.`,
714
+ next:"Exercise the feature normally in an authorised session."});
715
+ if(e.anon) sig.push({sev:"warn",ttl:"Seen with and without credentials",
716
+ why:"Some requests carried a credential and some did not, so it is unclear whether auth is required.",
717
+ next:"Replay it unauthenticated and compare the response."});
718
+ if(e.requests>0&&e.method!=="*"&&e.method!=="GET"&&e.method!=="HEAD"&&!e.credentials.length)
719
+ sig.push({sev:"info",ttl:"State-changing call without an observed credential",
720
+ why:`A ${e.method} request with no credential seen on it.`,
721
+ next:"Check whether it is meant to require authentication."});
722
+ return sig;
723
+ }
724
+ function priorities(){
725
+ const out=[];
726
+ EP.forEach(e=>{
727
+ const sig=signalsFor(e);
728
+ if(!sig.length)return;
729
+ sig.sort((a,b)=>SEV_RANK[a.sev]-SEV_RANK[b.sev]);
730
+ out.push({sev:sig[0].sev,ep:e,sig});
731
+ });
732
+ return out.sort((a,b)=>SEV_RANK[a.sev]-SEV_RANK[b.sev]||b.sig.length-a.sig.length||(b.ep.requests>0)-(a.ep.requests>0)||a.ep.label.localeCompare(b.ep.label));
733
+ }
734
+ const PRIO=priorities();
735
+
736
+ /* cookies missing flags → also priorities-worthy */
737
+ const weakCookies=(D.cookies||[]).filter(k=>!k.httponly||!k.secure);
738
+
739
+ /* ---------- views ---------- */
740
+ const hasRoles=(D.roles||[]).length>=2;
741
+ const VIEWS=[
742
+ {id:"overview",name:"Overview",grp:"Start here",ct:()=>null},
743
+ {id:"priorities",name:"To check",grp:"Start here",ct:()=>PRIO.length+weakCookies.length},
744
+ {id:"endpoints",name:"Endpoints",grp:"Start here",ct:()=>EP.length},
745
+ {id:"graph",name:"Map",grp:"Start here",ct:()=>null},
746
+ {id:"inventory",name:"Requests",grp:"Start here",ct:()=>EVL.length},
747
+ {id:"ask",name:"Ask",grp:"Start here",ct:()=>null},
748
+ {id:"reference",name:"Reference",grp:"Start here",ct:()=>null},
749
+ ];
750
+ let view="overview", filters={method:new Set(),host:new Set(),role:new Set(),cred:new Set()}, sortKey="label", sortDir=1, query="";
751
+
752
+ function renderNav(){
753
+ const nav=$("#nav");let html="",grp="";
754
+ VIEWS.filter(v=>v.cond!==false).forEach(v=>{
755
+ if(v.grp!==grp){grp=v.grp;html+=`<h3>${esc(grp)}</h3>`;}
756
+ const n=v.ct();
757
+ html+=`<button class="vbtn${v.id===view?" on":""}" data-v="${v.id}">${v.dot?`<span class="dot" style="background:${v.dot}"></span>`:""}${esc(v.name)}${n!=null?`<span class="ct">${n}</span>`:""}</button>`;
758
+ });
759
+ nav.innerHTML=html;
760
+ $$(".vbtn",nav).forEach(b=>b.onclick=()=>{view=b.dataset.v;render();});
761
+ }
762
+
763
+ /* ---------- shared renderers ---------- */
764
+ /* "*" is an endpoint named in code: the method was never seen, so say so */
765
+ function methodm(m){if(!m||m==="*")return '<span class="m any" title="Named in the code; no request was captured, so the method is not known">ANY</span>';return `<span class="m ${m!=='GET'?'w':''}">${esc(m)}</span>`;}
766
+ function statuses(ss){if(!ss||!ss.length)return '<span class="st">—</span>';return ss.map(s=>{const cls=s>=500?"r":s>=400?"y":s>=200&&s<300?"g":"";return `<span class="st ${cls}">${s}</span>`;}).join(" ");}
767
+ function matchQ(e){if(!query)return true;const q=query.toLowerCase();return (e.label+" "+(e.host||"")+" "+(e.params||[]).join(" ")+" "+(e.referenced_by||[]).join(" ")).toLowerCase().includes(q);}
768
+
769
+ __DASHJS__
770
+ function prioCard(p,i){
771
+ const e=p.ep, lead=p.sig[0];
772
+ const more=p.sig.slice(1).map(s=>`<div class="sig"><span><b>${esc(s.ttl)}.</b> ${esc(s.why)}</span></div>`).join("");
773
+ return `<div class="prio sev-${p.sev}" data-ep="${esc(e.id)}"><div class="rank">${i+1}</div><div class="body">
774
+ <div class="ttl">${methodm(e.method)} <span class="mono">${esc(e.label.replace(/^\S+\s/,''))}</span> <span style="color:var(--muted);font-weight:400;font-size:13px">${esc(lead.ttl)}</span></div>
775
+ <div class="why">${esc(lead.why)}</div>${more}<div class="nx"><b>Next</b> ${esc(lead.next)}</div></div></div>`;
776
+ }
777
+
778
+ /* ---------- ASK (answers from the model, no AI) ---------- */
779
+ const ARROW='<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--faint)"><path d="M9 6l6 6-6 6"/></svg>';
780
+ function arow(e){return `<div class="arow" data-ep="${esc(e.id)}">${methodm(e.method)}<span class="p mono">${esc(e.path)}</span><span class="ev">${(e.evidence||[]).slice(0,2).map(EVD).join(", ")}</span>${ARROW}</div>`;}
781
+ function alist(title,note,rows){return {title,html:`<div class="answer">${note?`<p class="note">${note}</p>`:""}${rows.length?rows.join(""):'<div class="empty">Nothing in this model matches.</div>'}</div>`};}
782
+ function ans_endpoints(){const r=[...EP].sort((a,b)=>a.path.localeCompare(b.path));return alist("Endpoints",`The model holds <b>${r.length}</b> API endpoint${r.length===1?"":"s"}. Click any to see its parameters and the request that produced it.`,r.map(arow));}
783
+ function ans_incode(){const r=EP.filter(e=>e.state==="STATIC_ONLY").sort((a,b)=>a.path.localeCompare(b.path));return alist("Referenced in code, never called",r.length?`These URLs appear in the client code but no request to them was captured, unwalked paths worth trying.`:`Every endpoint referenced in the code was also seen in traffic.`,r.map(arow));}
784
+ function ans_errors(){const r=EP.filter(e=>e.statuses.length&&e.statuses.every(s=>s>=400)).sort((a,b)=>a.path.localeCompare(b.path));return alist("Only ever returned errors",r.length?`Every response captured for these was 4xx/5xx, their behaviour with valid input and auth is unseen.`:`No endpoint returned only errors.`,r.map(arow));}
785
+ function ans_priv(){const r=EP.filter(e=>e.privileged).sort((a,b)=>a.path.localeCompare(b.path));return alist("Privileged-looking paths",r.length?`Paths that match an admin/internal pattern.`:`No privileged-looking paths in this capture.`,r.map(arow));}
786
+ function ans_thirdparty(){const r=(D.third_parties||[]);return {title:"Third parties",html:`<div class="answer"><p class="note">${r.length} host${r.length===1?"":"s"} outside the app's own domain, code you don't control running in your users' context.</p>${r.map(t=>`<div class="arow"><span class="p mono">${esc(t.label)}</span><span class="ev">${t.requests} req</span></div>`).join("")||'<div class="empty">None.</div>'}</div>`};}
787
+ function ans_auth(){const a=D.auth||[],k=D.cookies||[];return {title:"Authentication & cookies",html:`<div class="answer"><p class="note">How the app carries identity.</p>
788
+ ${a.map(x=>`<div class="arow"><span class="p mono">${esc(x.label)}</span><span class="ev">${x.endpoints.length} endpoint${x.endpoints.length===1?"":"s"}</span></div>`).join("")}
789
+ ${k.map(x=>{const miss=[!x.httponly&&"HttpOnly",!x.secure&&"Secure"].filter(Boolean);return `<div class="arow"><span class="p mono">cookie ${esc(x.name)}</span><span class="ev">${miss.length?"missing "+miss.join("+"):"HttpOnly+Secure"}</span></div>`;}).join("")}
790
+ ${!a.length&&!k.length?'<div class="empty">No credentials or cookies observed.</div>':""}</div>`};}
791
+ function ans_unknowns(){return {title:"Open questions",html:`<div class="answer"><p class="note">What the capture could not answer.</p>${(D.unknowns||[]).map(u=>`<div class="arow"><span class="p">${esc(openTitle(u.type))}, <span class="mono" style="color:var(--muted)">${esc(u.entity)}</span></span></div>`).join("")||'<div class="empty">Nothing left open.</div>'}</div>`};}
792
+ function ans_endpoint(path){
793
+ const q=path.toLowerCase();const hits=EP.filter(e=>e.path.toLowerCase().includes(q)).sort((a,b)=>a.path.length-b.path.length);
794
+ if(!hits.length)return {title:"No such endpoint",html:`<div class="answer"><p class="note">Nothing in the model matches <span class="mono">${esc(path)}</span>. The model can only answer for endpoints it actually holds, that's the point.</p></div>`};
795
+ return {title:"Endpoint",html:`<div class="answer">${hits.slice(0,6).map(e=>{
796
+ const bits=[];if(e.statuses.length)bits.push("status "+e.statuses.join("/"));if(e.params.length)bits.push(e.params.length+" params");if(e.referenced_by.length)bits.push("referenced by "+e.referenced_by.join(", "));if(e.called_from.length)bits.push("called from "+e.called_from.join(", "));
797
+ return `<div class="arow" data-ep="${esc(e.id)}">${methodm(e.method)}<span class="p mono">${esc(e.path)}</span>${ARROW}</div><p class="note" style="margin:-2px 0 12px 6px">${esc(bits.join(" · ")||"seen in traffic")}</p>`;}).join("")}</div>`};
798
+ }
799
+ function ans_shape(){const c2=D.counts;return {title:D.app,html:`<div class="answer"><p class="note"><b>${esc(D.app)}</b>, ${c2.hosts} host${c2.hosts===1?"":"s"}, ${c2.routes} route${c2.routes===1?"":"s"}, <b>${c2.endpoints} endpoints</b>, ${c2.parameters} parameters, ${c2.third_parties} third part${c2.third_parties===1?"y":"ies"}, ${c2.unknowns} open question${c2.unknowns===1?"":"s"}. Ask about any of them, or open the Map.</p></div>`};}
800
+ function askEngine(q){
801
+ q=(q||"").toLowerCase().trim();
802
+ if(!q)return ans_shape();
803
+ const p=q.match(/\/[a-z0-9_\-{}\/.]+/i);if(p)return ans_endpoint(p[0]);
804
+ if(/(in ?code|referenced|static).*(never|not|un).*(call|walk|use)|never called|unwalked|only in code/.test(q))return ans_incode();
805
+ if(/error|fail|4\d\d|5\d\d|broken/.test(q))return ans_errors();
806
+ if(/admin|privileg|internal|manage/.test(q))return ans_priv();
807
+ if(/third|vendor|external|supply|dependen/.test(q))return ans_thirdparty();
808
+ if(/auth|login|cookie|session|credential|token|sign ?in/.test(q))return ans_auth();
809
+ if(/unknown|open question|couldn'?t|can'?t see|don'?t know|what.*miss|gap/.test(q))return ans_unknowns();
810
+ if(/param|field|input|argument/.test(q)){const r=EP.filter(e=>e.params.length).sort((a,b)=>b.params.length-a.params.length);return alist("Endpoints with parameters",`Parameter <b>names</b> only, values were masked at capture.`,r.map(e=>`<div class="arow" data-ep="${esc(e.id)}">${methodm(e.method)}<span class="p mono">${esc(e.path)}</span><span class="ev">${e.params.length}</span>${ARROW}</div>`));}
811
+ if(/endpoint|api|route|url|path|list|what.*(exist|there)|surface/.test(q))return ans_endpoints();
812
+ return ans_shape();
813
+ }
814
+ __QRYJS__
815
+
816
+ /* ---------- INFRASTRUCTURE (external recon, stored in graph.db) ---------- */
817
+ function vInfra(){
818
+ const B=D.bql;
819
+ if(!B||!B.osint)return `<h1 class="vt">Infrastructure</h1><p class="vsub">External recon: DNS, certificates, subdomains, mail policy, headers.</p><div class="empty">No recon stored. Recon is opt-in: rebuild with <span class="mono">--osint</span>, or run <span class="mono">proxy2model osint HOST -w ${esc(D.app)} --yes</span>.</div>`;
820
+ const by={};(B.facts||[]).forEach(f=>{(by[f.section]=by[f.section]||[]).push(f);});
821
+ const order=["dns","tls","http","technology","email_security","registration","ip_geo","files","archive","ports","notes"];
822
+ const titles={dns:"DNS",tls:"TLS certificate",http:"HTTP headers",technology:"Technology",email_security:"Mail authentication",registration:"Registration",ip_geo:"Hosting",files:"robots / security.txt / sitemap",archive:"Web archive",ports:"Open ports",notes:"Notes"};
823
+ const cards=order.filter(k=>by[k]).map(k=>`<div class="card icard"><div class="hd">${esc(titles[k]||k)} <span class="ct">${by[k].length}</span></div><div class="bd"><table><tbody>${by[k].map(f=>`<tr><td class="k">${esc(f.key)}</td><td class="v">${esc(f.value)}</td></tr>`).join("")}</tbody></table></div></div>`).join("");
824
+ const byId=new Map((B.nodes||[]).map(n=>[n.id,n]));
825
+ const seenName=new Set(),subs=[];
826
+ (B.edges||[]).filter(e=>e.type==="SUBDOMAIN_OF"||e.type==="COVERS").forEach(e=>{
827
+ const n=byId.get(e.dst);if(!n||n.type==="wildcard_san"||seenName.has(n.id))return;seenName.add(n.id);
828
+ subs.push({label:n.label,inCapture:n.source==="traffic",via:(n.attrs&&n.attrs.via)||(e.type==="COVERS"?"certificate":"first-party host")});});
829
+ subs.sort((a,b)=>a.inCapture-b.inCapture||a.label.localeCompare(b.label));
830
+ const unseen=subs.filter(n=>!n.inCapture);
831
+ const subCard=subs.length?`<div class="card icard"><div class="hd">Subdomains <span class="ct">${subs.length}</span></div><div class="bd"><table><thead><tr><th>Name</th><th>In your capture?</th><th>Found via</th></tr></thead><tbody>${
832
+ subs.map(n=>`<tr><td class="mono">${esc(n.label)}</td><td class="${n.inCapture?"":"miss"}">${n.inCapture?"yes":"never visited"}</td><td class="mono" style="color:var(--muted)">${esc(n.via)}</td></tr>`).join("")}</tbody></table></div></div>`:"";
833
+ return `<h1 class="vt">Infrastructure <span class="tag-ext">EXTERNAL</span></h1><p class="vsub">Public recon on <b class="mono">${esc(B.osint.host)}</b> (run #${B.osint.id}, ${esc(B.osint.generated_at)}). It lives in the graph as <span class="mono">EXTERNAL</span> edges, a separate provenance from traffic you captured or code that referenced a path. ${unseen.length?`<b>${unseen.length}</b> of ${subs.length} subdomain(s) never appeared in your capture.`:""}</p>${subCard}${cards}`;
834
+ }
835
+
836
+ /* ---------- PRIORITIES ---------- */
837
+ /* ---------- CROSS-ROLE ---------- */
838
+ function vCrossrole(){
839
+ const roles=D.roles||[];
840
+ const reached=EP.filter(e=>e.roles.length);
841
+ let h=`<h1 class="vt">Cross-role surface</h1><p class="vsub">Which role reached which endpoint, and the status each got back. Divergence is a broken-access-control <b>hypothesis</b>, replay across accounts to confirm.</p>`;
842
+ // privileged / divergence highlights — one card per endpoint, notes merged
843
+ const hiBy={};
844
+ const note=(e,t)=>{(hiBy[e.id]=hiBy[e.id]||{e,notes:[]}).notes.push(t);};
845
+ reached.forEach(e=>{
846
+ const sbr=e.status_by_role||{};
847
+ const ok=r=>(sbr[r]||[]).some(s=>s>=200&&s<300);
848
+ if(e.privileged&&e.roles.length) note(e,"privileged path reached by "+e.roles.join(", "));
849
+ roles.forEach(lo=>roles.forEach(hiR=>{if(lo!==hiR&&(sbr[lo]||[]).some(s=>s===403||s===401)&&ok(hiR))note(e,`${lo} got ${sbr[lo].join("/")}, ${hiR} got ${sbr[hiR].join("/")}`);}));
850
+ });
851
+ const hi=Object.values(hiBy).map(x=>({e:x.e,note:x.notes.join(" · ")}));
852
+ if(hi.length){h+=`<div class="card"><div class="hd">Divergences worth checking <span class="ct">${hi.length}</span></div><div class="bd" style="padding:14px 16px">${hi.map(x=>`<div class="prio sev-hot" data-ep="${esc(x.e.id)}"><div class="rank">⚑</div><div class="body"><div class="ttl">${methodm(x.e.method)} <span class="mono">${esc(x.e.path)}</span></div><div class="why">${esc(x.note)}.</div></div></div>`).join("")}</div></div>`;}
853
+ // matrix
854
+ h+=`<div class="card"><div class="hd">Role reach matrix</div><div class="bd" style="padding:0"><table class="matrix"><thead><tr><th>Endpoint</th>${roles.map(r=>`<th>${esc(r)}</th>`).join("")}</tr></thead><tbody>`;
855
+ h+=reached.filter(e=>matchQ(e)).map(e=>`<tr data-ep="${esc(e.id)}"><td><span class="mono">${methodm(e.method)} ${esc(e.path)}</span></td>${roles.map(r=>{const ss=(e.status_by_role||{})[r];return `<td>${ss?`<span class="cell hit">${ss.join(",")}</span>`:'<span class="cell miss">·</span>'}</td>`;}).join("")}</tr>`).join("");
856
+ h+=`</tbody></table></div></div>`;
857
+ return h;
858
+ }
859
+
860
+ /* ---------- UNKNOWNS ---------- */
861
+ const OPEN_TITLE={
862
+ AUTHENTICATED_STATE_NOT_OBSERVED:"No signed-in traffic in this capture",
863
+ ROLE_NOT_TAGGED:"Traffic was not tagged with a role",
864
+ API_PURPOSE_UNKNOWN:"Referenced in code but never called",
865
+ AUTHORIZATION_UNKNOWN:"Access control not verified",
866
+ ENDPOINT_ONLY_ERRORED:"Only ever returned errors",
867
+ SCRIPT_PARTIALLY_SCANNED:"Script too large to read in full",
868
+ CAPTURE_ITEMS_SKIPPED:"Some requests could not be parsed",
869
+ };
870
+ function openTitle(t){return OPEN_TITLE[t]||humanize(t);}
871
+ __PGJS__
872
+
873
+ __RRJS__
874
+ __INVJS__
875
+ function openEv(i){
876
+ const ev=D.evidence["ev_"+i];if(!ev)return;
877
+ $("#dtitle").innerHTML=`<span class="mono">${EVD(i)}</span>`;
878
+ $("#dbody").innerHTML=`<h4>Attributes</h4><div class="kv">
879
+ <div class="k">Request</div><div class="v mono">${esc(ev.method)} ${esc(ev.host)}${esc(ev.path)}</div>
880
+ <div class="k">Status</div><div class="v mono">${ev.status==null?"?":ev.status}</div>
881
+ ${ev.mime?`<div class="k">MIME</div><div class="v mono">${esc(ev.mime)}</div>`:""}
882
+ ${ev.role?`<div class="k">Role</div><div class="v mono">${esc(ev.role)}</div>`:""}
883
+ ${ev.source?`<div class="k">Source</div><div class="v mono">${esc(ev.source)}${ev.item!=null?` · item ${ev.item}`:""}</div>`:""}
884
+ </div><h4>Request &amp; response</h4><div class="drv" id="drv"></div>`;
885
+ rrMount($("#drv"),[i],{vert:true,title:" "});
886
+ $("#drawer").classList.add("on");$("#scrim").classList.add("on");$("#drawer").setAttribute("aria-hidden","false");
887
+ }
888
+
889
+ __MAPJS__
890
+
891
+ /* ---------- drawer ---------- */
892
+ function openEp(id){
893
+ const e=EP.find(x=>x.id===id);if(!e)return;
894
+ const sbr=e.status_by_role||{};
895
+ const staticOnly=e.state==="STATIC_ONLY";
896
+ const kv=(k,v)=>v!=null&&v!==""&&!(Array.isArray(v)&&!v.length)?`<div class="k">${k}</div><div class="v">${v}</div>`:"";
897
+ $("#dtitle").innerHTML=`${methodm(e.method)} <span class="mono">${esc(e.path)}</span>`;
898
+ $("#dbody").innerHTML=`
899
+ ${staticOnly?`<div class="callout" style="margin-bottom:16px"><span class="i">i</span><p>This endpoint was <b>referenced in the client code but never requested</b> in the capture, so there is no request or response for it. The evidence below is the page or script that names it, a good thing to try next.</p></div>`:""}
900
+ <h4>Attributes</h4><div class="kv">
901
+ ${kv("Host",`<span class="mono">${esc(e.host)}</span>`)}
902
+ ${kv("Statuses",statuses(e.statuses))}${kv("Requests",e.requests)}
903
+ ${kv("Credentials",e.credentials.map(x=>`<span class="m">${esc(x)}</span>`).join(" "))}
904
+ ${e.anon?kv("Note",'<span style="color:var(--warn)">also seen without credentials</span>'):""}
905
+ ${e.privileged?kv("Note",'<span style="color:var(--signal)">privileged-looking path</span>'):""}
906
+ </div>
907
+ ${e.roles.length?`<h4>Reached by</h4><div class="kv">${e.roles.map(r=>`<div class="k mono">${esc(r)}</div><div class="v mono">${(sbr[r]||[]).join(", ")||"—"}</div>`).join("")}</div>`:""}
908
+ ${e.params.length?`<h4>Parameters (${e.params.length})</h4><div class="taglist">${e.params.map(p=>`<span>${esc(p)}</span>`).join("")}</div>`:""}
909
+ ${e.referenced_by.length?`<h4>Referenced by (inferred)</h4><div class="taglist">${e.referenced_by.map(p=>`<span>${esc(p)}</span>`).join("")}</div>`:""}
910
+ ${e.called_from.length?`<h4>Called from (observed)</h4><div class="taglist">${e.called_from.map(p=>`<span>${esc(p)}</span>`).join("")}</div>`:""}
911
+ <h4>Evidence: request and response</h4>${(e.evidence||[]).length?'<div class="drv" id="drv"></div>':'<div style="color:var(--faint)">None captured.</div>'}`;
912
+ if((e.evidence||[]).length)rrMount($("#drv"),e.evidence,{vert:true,title:" "});
913
+ $("#drawer").classList.add("on");$("#scrim").classList.add("on");$("#drawer").setAttribute("aria-hidden","false");
914
+ }
915
+ const NBYID=Object.fromEntries((D.graph.nodes||[]).map(n=>[n.id,n]));
916
+ const NODE_KIND={infra:"Infrastructure (external recon)",host:"First-party host",tech:"Technology marker (from response headers)",route:"Page / route",script:"Client script",third_party:"Third party",auth:"Auth scheme",cookie:"Cookie",role:"Role",operation:"GraphQL operation",parameter:"Parameter"};
917
+ function openNode(id){
918
+ if(EP.find(x=>x.id===id))return openEp(id);
919
+ const n=NBYID[id];if(!n)return;
920
+ $("#dtitle").innerHTML=`<span class="mono">${esc(n.label)}</span>`;
921
+ const conns=(D.graph.edges||[]).filter(e=>e.s===id||e.d===id).slice(0,30);
922
+ $("#dbody").innerHTML=`<h4>Attributes</h4><div class="kv">
923
+ <div class="k">Kind</div><div class="v">${esc(NODE_KIND[n.type]||humanize(n.type))}</div>
924
+ ${n.detail?`<div class="k">Detail</div><div class="v">${esc(n.detail)}</div>`:""}
925
+ </div>
926
+ ${n.type==="infra"?`<div class="callout" style="margin-top:14px"><span class="i">i</span><p>Collected by external recon (<span class="mono">proxy2model osint</span>), not from the capture. It describes the app's public footprint.</p></div>`:""}
927
+ ${conns.length?`<h4>Connections (${conns.length})</h4><div class="taglist">${conns.map(e=>{const o=e.s===id?e.d:e.s;const nn=NBYID[o];const dir=e.s===id?"→":"←";return `<span>${dir} ${esc(humanize(e.type))}: ${esc(nn?nn.label:o)}</span>`;}).join("")}</div>`:""}
928
+ ${(n.evidence||[]).length?'<h4>Evidence: request and response</h4><div class="drv" id="drv"></div>':""}`;
929
+ if((n.evidence||[]).length)rrMount($("#drv"),n.evidence,{vert:true,title:" "});
930
+ $("#drawer").classList.add("on");$("#scrim").classList.add("on");$("#drawer").setAttribute("aria-hidden","false");
931
+ }
932
+ function closeDrawer(){$("#drawer").classList.remove("on");$("#scrim").classList.remove("on");$("#drawer").setAttribute("aria-hidden","true");}
933
+ $("#scrim").onclick=closeDrawer;$("#dclose").onclick=closeDrawer;
934
+ addEventListener("keydown",e=>{if(e.key==="Escape")closeDrawer();});
935
+
936
+ /* ---------- wire ---------- */
937
+ const RENDER={overview:vOverview,priorities:vPriorities,endpoints:vEndpoints,graph:vGraph,inventory:vEvidence,ask:vAsk,reference:vReference};
938
+ /* the address bar uses the names the nav shows: report.html#map, #requests, #check */
939
+ const HASH_OF={graph:"map",inventory:"requests",priorities:"check"};
940
+ const VIEW_OF=Object.fromEntries(Object.entries(HASH_OF).map(([a,b])=>[b,a]));
941
+ function render(){
942
+ renderNav();
943
+ try{if(!(view==="ask"&&(location.hash||"").startsWith("#query=")))history.replaceState(null,"","#"+(HASH_OF[view]||view));}catch(e){}
944
+ const main=$("#main");
945
+ main.className="m-"+view;
946
+ main.innerHTML=`<section class="view view-${view} on">${(RENDER[view]||vOverview)()}</section>
947
+ <div class="foot">Generated by <b>proxy2model ${esc(D.version)}</b>${D.generated?` on ${esc(D.generated)}`:""}. An evidence-backed model of a capture you provided. Not a scanner; nothing here is a vulnerability. · <a href="https://falc0n-researcher.github.io/proxy2model/">docs</a></div>`;
948
+ if(view==="graph")initGraph();
949
+ if(view==="ask")initAsk();
950
+ if(view==="overview")initOverview();
951
+ if(view==="priorities")initPriorities();
952
+ if(view==="endpoints")initEndpoints();
953
+ if(view==="reference")initReference();
954
+ if(view==="inventory")initEvq();
955
+ // row / card clicks → drawer
956
+ $$("[data-ep]",main).forEach(el=>el.onclick=()=>openEp(el.dataset.ep));
957
+ $$("[data-ev]",main).forEach(el=>el.onclick=()=>openEv(el.dataset.ev));
958
+ $$("th[data-sort]",main).forEach(th=>th.onclick=()=>{const k=th.dataset.sort;if(sortKey===k)sortDir*=-1;else{sortKey=k;sortDir=1;}render();});
959
+ $$(".chip[data-fk]",main).forEach(ch=>ch.onclick=()=>{const s=filters[ch.dataset.fk];s.has(ch.dataset.fv)?s.delete(ch.dataset.fv):s.add(ch.dataset.fv);render();});
960
+ $$("[data-goto]",main).forEach(b=>b.onclick=()=>{go(b.dataset.goto);render();});
961
+ }
962
+ $("#q").addEventListener("input",e=>{query=e.target.value.trim();
963
+ // a search jumps to the most relevant list view if on a summary view
964
+ // on the Map, a search highlights matching nodes in place instead
965
+ REF.q=query;EPV.q=query;if(query&&["overview","ask","priorities"].includes(view))go("endpoints");
966
+ render();});
967
+ addEventListener("keydown",e=>{
968
+ if(e.key==="/"&&!e.metaKey&&!e.ctrlKey&&!/^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName)){e.preventDefault();$("#q").focus();}
969
+ });
970
+ /* deep link: report.html#priorities opens that view */
971
+ /* report.html#query=<urlencoded BQL> opens the console with that query already run */
972
+ function fromHash(){
973
+ const h=(location.hash||"").slice(1);
974
+ if(h.startsWith("query=")){let q="";try{q=decodeURIComponent(h.slice(6));}catch(e){}view="ask";if(q&&q!==qText){qText=q;aNL=null;qErr=null;qRes=null;if(looksBql(q)&&BQLDB)qRun(q);else aNL=askEngine(q);}return true;}
975
+ if(h==="evidence"){view="inventory";return true;}
976
+ if(h==="query"){view="ask";return true;}
977
+ if(LEGACY[h]){go(h);return true;}
978
+ if(VIEW_OF[h]){view=VIEW_OF[h];return true;}
979
+ if(RENDER[h]){view=h;return true;}
980
+ return false;
981
+ }
982
+ fromHash();
983
+ addEventListener("hashchange",()=>{const was=view,h=(location.hash||"").slice(1);if(fromHash()&&(view!==was||view==="ask"||LEGACY[h]))render();});
984
+ render();
985
+ </script>
986
+ </body>
987
+ </html>
988
+ """