proxy2model 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- proxy2model/__init__.py +19 -0
- proxy2model/__main__.py +6 -0
- proxy2model/atomic.py +68 -0
- proxy2model/cli/__init__.py +3 -0
- proxy2model/cli/analyze.py +501 -0
- proxy2model/cli/app.py +408 -0
- proxy2model/cli/browse.py +279 -0
- proxy2model/cli/build.py +399 -0
- proxy2model/cli/common.py +190 -0
- proxy2model/cli/history.py +224 -0
- proxy2model/cli/mcp.py +184 -0
- proxy2model/cli/ui.py +62 -0
- proxy2model/core/__init__.py +1 -0
- proxy2model/core/changes.py +124 -0
- proxy2model/core/context.py +571 -0
- proxy2model/core/diff.py +330 -0
- proxy2model/core/domains.py +98 -0
- proxy2model/core/graph.py +562 -0
- proxy2model/core/methodology.py +343 -0
- proxy2model/core/model.py +998 -0
- proxy2model/core/stack.py +218 -0
- proxy2model/data/fonts/LICENSES.md +16 -0
- proxy2model/data/fonts/inter-latin-wght-normal.woff2 +0 -0
- proxy2model/data/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
- proxy2model/data/fonts/nippo-200.woff2 +0 -0
- proxy2model/data/fonts/nippo-300.woff2 +0 -0
- proxy2model/data/fonts/nippo-400.woff2 +0 -0
- proxy2model/data/fonts/nippo-500.woff2 +0 -0
- proxy2model/data/fonts/nippo-700.woff2 +0 -0
- proxy2model/data/public_suffix_list.dat +16497 -0
- proxy2model/db/__init__.py +1 -0
- proxy2model/db/bql.py +710 -0
- proxy2model/db/bql_js.py +278 -0
- proxy2model/db/store.py +547 -0
- proxy2model/export/__init__.py +1 -0
- proxy2model/export/openapi.py +248 -0
- proxy2model/ingest/__init__.py +1 -0
- proxy2model/ingest/browse.py +181 -0
- proxy2model/ingest/browser.py +1058 -0
- proxy2model/ingest/crawl.py +673 -0
- proxy2model/ingest/extract.py +208 -0
- proxy2model/ingest/infer.py +75 -0
- proxy2model/ingest/journey.py +148 -0
- proxy2model/ingest/live.py +96 -0
- proxy2model/ingest/parse.py +1275 -0
- proxy2model/ingest/redact.py +535 -0
- proxy2model/py.typed +0 -0
- proxy2model/recon/__init__.py +1 -0
- proxy2model/recon/osint.py +607 -0
- proxy2model/report/__init__.py +1 -0
- proxy2model/report/animate.py +163 -0
- proxy2model/report/dash.py +469 -0
- proxy2model/report/fonts.py +51 -0
- proxy2model/report/html.py +988 -0
- proxy2model/report/inventory.py +258 -0
- proxy2model/report/map.py +515 -0
- proxy2model/report/pages.py +537 -0
- proxy2model/report/query.py +306 -0
- proxy2model/report/shot.py +55 -0
- proxy2model/report/view.py +213 -0
- proxy2model-2.0.0.dist-info/METADATA +148 -0
- proxy2model-2.0.0.dist-info/RECORD +66 -0
- proxy2model-2.0.0.dist-info/WHEEL +5 -0
- proxy2model-2.0.0.dist-info/entry_points.txt +2 -0
- proxy2model-2.0.0.dist-info/licenses/LICENSE +201 -0
- proxy2model-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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 & 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
|
+
"""
|