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,571 @@
1
+ """
2
+ Deterministic queries over the model, plus the AI context package.
3
+
4
+ The queries answer facts with no language model involved — every line cites an
5
+ evidence index. The context package is what you hand an LLM *instead of* a raw
6
+ dump: the structure, not the bytes, with strict rules that keep the model
7
+ honest. Every evidence id the package cites is resolved in its `evidence`
8
+ table (method, host, templated path, status, role, source item), so a claim
9
+ can be checked against the capture without the capture being in the prompt.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+
16
+ from .model import PRIV_RE, TELEMETRY_RE, Model
17
+
18
+ SOURCE = "Source: Knowledge Graph · Model call: none"
19
+
20
+ LENSES = ("attention", "auth", "all")
21
+
22
+
23
+ def _ev(n) -> str:
24
+ return f"ev_{n.evidence[0]}" if n.evidence else "ev_?"
25
+
26
+
27
+ def _endpoints(m: Model):
28
+ return [n for n in m.nodes.values() if n.type == "endpoint"]
29
+
30
+
31
+ def _params(m: Model, node_id: str) -> list[str]:
32
+ return sorted(m.nodes[e.dst].label for e in m.edges
33
+ if e.src == node_id and e.type == "USES_PARAMETER" and e.dst in m.nodes)
34
+
35
+
36
+ def _mentioned_endpoints(m: Model, question: str):
37
+ """Endpoints whose templated path appears in the question (longest first)."""
38
+ hits = []
39
+ for n in _endpoints(m):
40
+ path = n.attrs.get("path", "")
41
+ if len(path) > 1 and path in question:
42
+ hits.append(n)
43
+ hits.sort(key=lambda n: -len(n.attrs.get("path", "")))
44
+ if not hits:
45
+ return []
46
+ best = len(hits[0].attrs["path"])
47
+ return [n for n in hits if len(n.attrs["path"]) == best]
48
+
49
+
50
+ HELP = """intents query() answers (no AI, every line cited):
51
+ list-apis every endpoint with state, statuses, evidence
52
+ reconcile code vs runtime (also: "code vs runtime")
53
+ list-routes every route (pages, SPA states, forms)
54
+ route-apis /x APIs a route calls, directly or through its scripts
55
+ provenance /api/orders where an endpoint was seen: evidence, statuses, roles, callers
56
+ third-parties off-scope hosts
57
+ auth-surface credentials, roles, cookie flags
58
+ unknowns what the capture could not answer
59
+ entity-evidence <name> evidence ids behind any node, by label
60
+ also: parameters, secrets (masked), privileged, errors, shape
61
+ anything else is refused: this answers from the model or says it cannot"""
62
+
63
+ # intent name -> canonical intent
64
+ _INTENTS = {
65
+ "list-apis": "list-apis", "list apis": "list-apis", "apis": "list-apis",
66
+ "endpoints": "list-apis", "list endpoints": "list-apis",
67
+ "reconcile": "reconcile", "code vs runtime": "reconcile",
68
+ "list-routes": "list-routes", "list routes": "list-routes", "routes": "list-routes",
69
+ "third-parties": "third-parties", "third parties": "third-parties",
70
+ "auth-surface": "auth-surface", "auth surface": "auth-surface",
71
+ "unknowns": "unknowns", "shape": "shape", "summary": "shape", "overview": "shape",
72
+ }
73
+ _WITH_ARG = ("route-apis", "provenance", "entity-evidence")
74
+
75
+
76
+ def _refuse(m: Model, question: str) -> str:
77
+ return "\n".join([
78
+ f"cannot answer {question!r} from the {m.name} model.",
79
+ " nothing here matches a known intent, and this never guesses.",
80
+ " try: proxy2model query <app> help",
81
+ SOURCE])
82
+
83
+
84
+ def _list_apis(m: Model) -> str:
85
+ eps = sorted(_endpoints(m), key=lambda n: n.label)
86
+ lines = [f"{len(eps)} endpoints"]
87
+ for n in eps:
88
+ lines.append(f" {n.label} [{n.attrs.get('api_state')}] "
89
+ f"{n.attrs.get('statuses', [])} {_ev(n)}")
90
+ lines.append(SOURCE)
91
+ return "\n".join(lines)
92
+
93
+
94
+ def _list_routes(m: Model) -> str:
95
+ rs = sorted((n for n in m.nodes.values() if n.type == "route"), key=lambda n: n.label)
96
+ lines = [f"{len(rs)} routes"]
97
+ for n in rs:
98
+ lines.append(f" {n.label} {_ev(n)}")
99
+ lines.append(SOURCE)
100
+ return "\n".join(lines)
101
+
102
+
103
+ def _route_apis(m: Model, arg: str) -> str:
104
+ routes = [n for n in m.nodes.values() if n.type == "route" and n.label == arg]
105
+ if not routes:
106
+ routes = sorted((n for n in m.nodes.values()
107
+ if n.type == "route" and arg and arg in n.label),
108
+ key=lambda n: (len(n.label), n.label))[:1]
109
+ if not routes:
110
+ return "\n".join([f"no route matching {arg!r} in the {m.name} model "
111
+ "(see: list-routes)", SOURCE])
112
+ r = routes[0]
113
+ scripts = {e.dst for e in m.edges if e.src == r.id and e.type == "INCLUDES"}
114
+ lines = [f"route {r.label}"]
115
+ seen = set()
116
+ for e in m.edges:
117
+ if e.type not in ("CALLS", "REFERENCES") or e.dst in seen:
118
+ continue
119
+ dst = m.nodes.get(e.dst)
120
+ if dst is None or dst.type != "endpoint":
121
+ continue
122
+ if e.src == r.id:
123
+ via = "directly"
124
+ elif e.src in scripts:
125
+ via = f"via {m.nodes[e.src].label}"
126
+ else:
127
+ continue
128
+ seen.add(e.dst)
129
+ lines.append(f" {dst.label} [{e.state}] {via} "
130
+ f"ev_{e.evidence[0] if e.evidence else '?'}")
131
+ if not seen:
132
+ lines.append(" no API linked to this route in the capture "
133
+ "(a gap in observation, not a statement about the target)")
134
+ lines.append(SOURCE)
135
+ return "\n".join(lines)
136
+
137
+
138
+ def _entity_evidence(m: Model, arg: str) -> str:
139
+ hits = sorted((n for n in m.nodes.values() if arg and (n.label == arg or arg in n.label)),
140
+ key=lambda n: (n.label != arg, len(n.label), n.label))[:10]
141
+ if not hits:
142
+ return "\n".join([f"no entity matching {arg!r} in the {m.name} model", SOURCE])
143
+ by_id = {e["id"]: e for e in m.evidence_log.meta()}
144
+ lines = []
145
+ for n in hits:
146
+ lines.append(f"{n.type} {n.label}")
147
+ for i in n.evidence[:5]:
148
+ e = by_id.get(i)
149
+ what = f"{e['method']} {e['host']}{e['path']} -> {e['status']}" if e else ""
150
+ lines.append(f" ev_{i} {what}")
151
+ if not n.evidence:
152
+ lines.append(" no evidence ids recorded")
153
+ lines.append(SOURCE)
154
+ return "\n".join(lines)
155
+
156
+
157
+ def query(m: Model, question: str) -> str:
158
+ """Answer a factual question deterministically. No LLM."""
159
+ q = question.lower().strip()
160
+ lines: list[str] = []
161
+
162
+ if q in ("help", "?", "") or q.startswith("help"):
163
+ return HELP
164
+
165
+ q = q.replace("_", "-")
166
+ intent = _INTENTS.get(q)
167
+ if intent == "list-apis":
168
+ return _list_apis(m)
169
+ if intent == "list-routes":
170
+ return _list_routes(m)
171
+ if intent == "shape":
172
+ return _shape(m)
173
+ if intent:
174
+ q = {"third-parties": "third parties", "auth-surface": "auth",
175
+ "reconcile": "code vs runtime"}.get(intent, intent)
176
+ for word in _WITH_ARG:
177
+ if q == word or q.startswith(word + " "):
178
+ arg = question.strip()[len(word):].strip()
179
+ if word == "route-apis":
180
+ return _route_apis(m, arg)
181
+ if word == "entity-evidence":
182
+ return _entity_evidence(m, arg)
183
+ if not arg:
184
+ return "\n".join(["provenance needs an endpoint, e.g. provenance /api/orders",
185
+ SOURCE])
186
+ q = "endpoint " + arg.lower()
187
+ question = "endpoint " + arg
188
+
189
+ if "code vs runtime" in q or "reconcile" in q or "static" in q:
190
+ eps = _endpoints(m)
191
+ states: dict[str, list] = {"BOTH": [], "STATIC_ONLY": [], "RUNTIME_ONLY": []}
192
+ for e in eps:
193
+ states.setdefault(e.attrs.get("api_state", "RUNTIME_ONLY"), []).append(e)
194
+ lines.append(f"APIs: {len(eps)} endpoints")
195
+ for st in ("BOTH", "STATIC_ONLY", "RUNTIME_ONLY"):
196
+ lines.append(f" {st:<12} {len(states.get(st, []))}")
197
+ static = sorted(states.get("STATIC_ONLY", []), key=lambda n: n.label)
198
+ for e in static[:20]:
199
+ lines.append(f" static-only {e.label} {_ev(e)}")
200
+ if len(static) > 20:
201
+ lines.append(f" … {len(static) - 20} more (see context.json)")
202
+ lines.append("")
203
+ lines.append(SOURCE)
204
+ return "\n".join(lines)
205
+
206
+ mentioned = _mentioned_endpoints(m, question)
207
+ if mentioned and "route" not in q:
208
+ for n in mentioned:
209
+ a = n.attrs
210
+ lines.append(f"endpoint {n.label} [{a.get('api_state')}]")
211
+ lines.append(f" evidence {', '.join(f'ev_{i}' for i in n.evidence[:8])}")
212
+ if a.get("statuses"):
213
+ lines.append(f" statuses {', '.join(map(str, a['statuses']))}")
214
+ if n.roles:
215
+ sbr = a.get("status_by_role", {})
216
+ lines.append(" reached by " + ", ".join(
217
+ f"{r} {sbr.get(r, [])}" for r in n.roles))
218
+ if a.get("credentials"):
219
+ lines.append(f" credentials {', '.join(a['credentials'])}")
220
+ if a.get("anonymous_requests"):
221
+ lines.append(f" anonymous {a['anonymous_requests']} request(s) without credentials")
222
+ params = _params(m, n.id)
223
+ if params:
224
+ lines.append(f" params {', '.join(params[:15])}")
225
+ for e in m.edges:
226
+ if e.dst == n.id and e.type in ("REFERENCES", "CALLS"):
227
+ src = m.nodes.get(e.src)
228
+ lines.append(f" {e.type.lower():<11} from {src.label if src else e.src}"
229
+ f" [{e.state}] ev_{e.evidence[0] if e.evidence else '?'}")
230
+ lines.append(SOURCE)
231
+ return "\n".join(lines)
232
+
233
+ if "route" in q and ("where" in q or "come from" in q):
234
+ routes = [n for n in m.nodes.values() if n.type == "route" and n.label in question]
235
+ routes.sort(key=lambda n: -len(n.label))
236
+ if routes:
237
+ n = routes[0]
238
+ lines.append(f"route {n.label}")
239
+ for e in n.evidence[:4]:
240
+ lines.append(f" observed at runtime ev_{e}")
241
+ for e in m.edges:
242
+ if e.dst == n.id and e.type == "NAVIGATES_TO":
243
+ lines.append(f" navigated from {m.nodes[e.src].label} ev_{e.evidence[0]}")
244
+ lines.append(SOURCE)
245
+ return "\n".join(lines)
246
+
247
+ if "unknown" in q or "not known" in q or "missing" in q:
248
+ lines.append(f"{len(m.unknowns)} named unknowns")
249
+ for u in m.unknowns[:20]:
250
+ ent = m.nodes[u.entity].label if u.entity in m.nodes else u.entity.split(":")[-1]
251
+ lines.append(f" {u.type:<34} {ent[:50]}")
252
+ if len(m.unknowns) > 20:
253
+ lines.append(f" … {len(m.unknowns) - 20} more (see context.json)")
254
+ lines.append("")
255
+ lines.append("absence = a gap in observation, never a statement about the target")
256
+ lines.append(SOURCE)
257
+ return "\n".join(lines)
258
+
259
+ if "param" in q:
260
+ eps = sorted(_endpoints(m), key=lambda n: n.label)
261
+ lines.append("parameters by endpoint (names only — values are never stored)")
262
+ for n in eps:
263
+ params = _params(m, n.id)
264
+ if params:
265
+ lines.append(f" {n.label} {_ev(n)}")
266
+ lines.append(f" {', '.join(params[:15])}")
267
+ lines.append(SOURCE)
268
+ return "\n".join(lines)
269
+
270
+ if "secret" in q or "credential leak" in q:
271
+ lines.append(f"{len(m.secrets)} distinct masked values (kind · length · entropy · seen)")
272
+ for s in sorted(m.secrets, key=lambda s: (-s["count"], s["kind"]))[:20]:
273
+ ev = ", ".join(f"ev_{i}" for i in s["evidence"][:3])
274
+ lines.append(f" {s['kind']:<22} {s['length']:>4} chars {s['entropy']:.2f} bits/char"
275
+ f" ×{s['count']} {ev}")
276
+ lines.append("values are never stored; fingerprints are keyed hashes")
277
+ lines.append(SOURCE)
278
+ return "\n".join(lines)
279
+
280
+ if any(w in q for w in ("auth", "role", "cookie", "session", "trust")):
281
+ lines.append(f"roles: {', '.join(sorted(m.roles)) or 'none tagged'}")
282
+ for n in sorted((n for n in m.nodes.values() if n.type == "auth"), key=lambda n: n.label):
283
+ used = sum(1 for e in m.edges if e.dst == n.id)
284
+ lines.append(f" auth {n.label} on {used} endpoint(s) {_ev(n)}")
285
+ for n in sorted((n for n in m.nodes.values() if n.type == "cookie"), key=lambda n: n.label):
286
+ a = n.attrs
287
+ flags = [f for f, on in (("HttpOnly", a.get("httponly")), ("Secure", a.get("secure"))) if on]
288
+ missing = [f for f, on in (("HttpOnly", a.get("httponly")), ("Secure", a.get("secure"))) if not on]
289
+ lines.append(f" cookie {n.label} set {'+'.join(flags) or 'no flags'}"
290
+ f"{' · missing ' + '+'.join(missing) if missing else ''}"
291
+ f" · SameSite={a.get('samesite') or 'unset'} {_ev(n)}")
292
+ anon = [n for n in _endpoints(m) if n.attrs.get("anonymous_requests") and n.attrs.get("credentials")]
293
+ for n in sorted(anon, key=lambda n: n.label)[:10]:
294
+ lines.append(f" mixed {n.label} seen with and without credentials {_ev(n)}")
295
+ lines.append(SOURCE)
296
+ return "\n".join(lines)
297
+
298
+ if "privileg" in q or "admin" in q:
299
+ eps = sorted((n for n in _endpoints(m) if PRIV_RE.search(n.attrs.get("path", ""))),
300
+ key=lambda n: n.label)
301
+ lines.append(f"{len(eps)} privileged-looking endpoints (path heuristic)")
302
+ for n in eps:
303
+ who = f" reached by {', '.join(n.roles)}" if n.roles else ""
304
+ lines.append(f" {n.label} [{n.attrs.get('api_state')}]{who} {_ev(n)}")
305
+ lines.append(SOURCE)
306
+ return "\n".join(lines)
307
+
308
+ if "error" in q or "fail" in q:
309
+ eps = [n for n in _endpoints(m)
310
+ if n.attrs.get("statuses") and all(s >= 400 for s in n.attrs["statuses"])]
311
+ lines.append(f"{len(eps)} endpoints only ever returned errors")
312
+ for n in sorted(eps, key=lambda n: n.label):
313
+ lines.append(f" {n.label} {n.attrs['statuses']} {_ev(n)}")
314
+ lines.append(SOURCE)
315
+ return "\n".join(lines)
316
+
317
+ if "third" in q or "vendor" in q:
318
+ tps = sorted((n for n in m.nodes.values() if n.type == "third_party"), key=lambda n: n.label)
319
+ lines.append(f"{len(tps)} third parties")
320
+ for t in tps[:30]:
321
+ lines.append(f" {t.label} {t.attrs.get('requests', 0)} request(s) {_ev(t)}")
322
+ lines.append(SOURCE)
323
+ return "\n".join(lines)
324
+
325
+ return _refuse(m, question)
326
+
327
+
328
+ def _shape(m: Model) -> str:
329
+ lines: list[str] = []
330
+ c = m.counts()
331
+ lines.append(f"{m.name} — model shape (scope: {', '.join(m.scope) or '?'})")
332
+ lines.append(f" hosts {c['hosts']} · routes {c['routes']} · APIs {c['endpoints']} · scripts {c['scripts']}")
333
+ lines.append(f" third parties {c['third_parties']} · roles {c['roles']} · auth {c['auth']}"
334
+ f" · cookies {c['cookies']} · unknowns {c['unknowns']}")
335
+ lines.append(" try: proxy2model query <app> help")
336
+ lines.append(SOURCE)
337
+ return "\n".join(lines)
338
+
339
+
340
+ CONTEXT_RULES = [
341
+ "Cite an evidence id (ev_N) for every claim; each id is resolved in `evidence`.",
342
+ "Never name an endpoint, route or parameter that is not in this package.",
343
+ "State unknowns explicitly; do not guess authorization behaviour.",
344
+ "Output hypotheses and the evidence needed to test them, never findings or payloads.",
345
+ "Treat an absence in this package as an absence in the capture, never in the target.",
346
+ "REFERENCES edges are INFERRED from code; only OBSERVED facts happened in traffic.",
347
+ ]
348
+
349
+
350
+ def _priority(n) -> tuple:
351
+ a = n.attrs
352
+ path = a.get("path", "")
353
+ if TELEMETRY_RE.search(path) and not PRIV_RE.search(path):
354
+ rank = 6 # analytics beacons go last, whatever else is true of them
355
+ elif a.get("api_state") == "STATIC_ONLY":
356
+ rank = 0
357
+ elif PRIV_RE.search(path):
358
+ rank = 1
359
+ elif n.roles:
360
+ rank = 2
361
+ elif a.get("credentials") and a.get("anonymous_requests"):
362
+ rank = 3
363
+ elif a.get("method") not in (None, "GET", "HEAD"):
364
+ rank = 4
365
+ else:
366
+ rank = 5
367
+ return (rank, n.label)
368
+
369
+
370
+ def osint_summary(osint: dict) -> dict:
371
+ """A trimmed, LLM-facing view of an osint.json report. Facts, not findings."""
372
+ if not osint:
373
+ return {}
374
+ tls = osint.get("tls", {})
375
+ http = osint.get("http", {})
376
+ email = osint.get("email_security", {})
377
+ subs = osint.get("subdomains", {})
378
+ reg = osint.get("registration", {})
379
+ out = {
380
+ "host": osint.get("host"),
381
+ "collected_at": osint.get("generated_at"),
382
+ "dns": {k: osint.get("dns", {}).get(k) for k in ("A", "AAAA", "MX", "NS", "CNAME")
383
+ if osint.get("dns", {}).get(k)},
384
+ "tls": {k: tls.get(k) for k in ("tls_version", "issuer", "not_after",
385
+ "days_until_expiry") if tls.get(k) is not None},
386
+ "security_headers_missing": http.get("security_headers_missing", []),
387
+ "technology": [t["name"] for t in osint.get("technology", [])],
388
+ "email_security": {k: email.get(k) for k in ("spf_note", "dmarc_policy", "dmarc_note",
389
+ "dkim_selector_found") if email.get(k)},
390
+ "subdomains_count": subs.get("count"),
391
+ "subdomains_sample": subs.get("names", [])[:25],
392
+ "registrar": reg.get("registrar"),
393
+ "hosting": {k: osint.get("ip_geo", {}).get(k) for k in ("network", "country")
394
+ if osint.get("ip_geo", {}).get(k)},
395
+ "robots_disallow": (osint.get("files", {}).get("robots") or {}).get("disallow", [])[:20],
396
+ "wayback_last_snapshot": (osint.get("archive") or {}).get("last_snapshot"),
397
+ "open_ports": [p["port"] for p in osint.get("ports", {}).get("open", [])],
398
+ "notes": osint.get("notes", []),
399
+ "provenance": "external recon, collected live against the target; not from the capture",
400
+ }
401
+ return {k: v for k, v in out.items() if v not in (None, [], {}, "")}
402
+
403
+
404
+ def context_package(m: Model, lens: str = "attention", cap: int = 40,
405
+ osint: dict | None = None, graph: bool = True) -> dict:
406
+ """Build the evidence package for an LLM. Capped and honest about it.
407
+
408
+ lens:
409
+ attention every endpoint, most interesting first (STATIC_ONLY,
410
+ privileged-looking, role-reached, mixed auth, state-changing)
411
+ auth only endpoints with credentials or roles, plus the trust layer
412
+ all every endpoint in model order
413
+
414
+ graph: also hand the model the graph itself (adjacency + reasoning views)
415
+ so it can traverse and reason about structure, not just read a list.
416
+ """
417
+ if lens not in LENSES:
418
+ raise ValueError(f"unknown lens {lens!r}; choose one of {', '.join(LENSES)}")
419
+
420
+ nodes = list(m.nodes.values())
421
+ eps_all = [n for n in nodes if n.type == "endpoint"]
422
+ if lens == "auth":
423
+ eps_all = [n for n in eps_all if n.roles or n.attrs.get("credentials")]
424
+ if lens != "all":
425
+ eps_all.sort(key=_priority)
426
+ routes_all = [n for n in nodes if n.type == "route"]
427
+ scripts_all = [n for n in nodes if n.type == "script"]
428
+ tps_all = sorted((n for n in nodes if n.type == "third_party"),
429
+ key=lambda n: -n.attrs.get("requests", 0))
430
+
431
+ rels: dict[str, dict[str, list[str]]] = {}
432
+ refs_out: dict[str, int] = {}
433
+ params_of: dict[str, list[str]] = {}
434
+ for e in m.edges:
435
+ if e.type == "REFERENCES":
436
+ refs_out[e.src] = refs_out.get(e.src, 0) + 1
437
+ elif e.type == "USES_PARAMETER" and e.dst in m.nodes:
438
+ params_of.setdefault(e.src, []).append(m.nodes[e.dst].label)
439
+ if e.type in ("REFERENCES", "CALLS"):
440
+ src = m.nodes.get(e.src)
441
+ if src is not None:
442
+ key = "referenced_by" if e.type == "REFERENCES" else "called_from"
443
+ rels.setdefault(e.dst, {}).setdefault(key, []).append(src.label)
444
+
445
+ cited: set[int] = set()
446
+
447
+ def evs(n, k=3):
448
+ ids = n.evidence[:k]
449
+ cited.update(ids)
450
+ return [f"ev_{i}" for i in ids]
451
+
452
+ def endpoint_view(n):
453
+ a = n.attrs
454
+ v = {"id": n.id, "type": n.type, "label": n.label, "api_state": a.get("api_state"),
455
+ "evidence": evs(n)}
456
+ if a.get("statuses"):
457
+ v["statuses"] = a["statuses"]
458
+ params = sorted(params_of.get(n.id, []))
459
+ if params:
460
+ v["params"] = params[:12]
461
+ if n.roles:
462
+ v["reached_by"] = n.roles
463
+ v["status_by_role"] = a.get("status_by_role", {})
464
+ if a.get("credentials"):
465
+ v["credentials"] = a["credentials"]
466
+ if a.get("anonymous_requests") and a.get("credentials"):
467
+ v["also_seen_without_credentials"] = True
468
+ for k, vals in rels.get(n.id, {}).items():
469
+ v[k] = sorted(set(vals))[:5]
470
+ if a.get("method_known") is False:
471
+ v["method"] = "unknown (static reference)"
472
+ return v
473
+
474
+ def simple_view(n):
475
+ return {"id": n.id, "type": n.type, "label": n.label, "evidence": evs(n)}
476
+
477
+ endpoints = eps_all[:cap]
478
+ routes = routes_all[:cap]
479
+ scripts = scripts_all[:cap]
480
+ tps = tps_all[:cap]
481
+ # AUTHORIZATION_UNKNOWN exists for every role-reached endpoint, so when the
482
+ # cap bites, keep the rarer, more specific unknowns first (stable order)
483
+ unknowns = sorted(m.unknowns, key=lambda u: u.type == "AUTHORIZATION_UNKNOWN")[:cap]
484
+
485
+ trust = {
486
+ "auth": [dict(simple_view(n)) for n in nodes if n.type == "auth"],
487
+ "cookies": [{**simple_view(n), "httponly": n.attrs.get("httponly"),
488
+ "secure": n.attrs.get("secure"), "samesite": n.attrs.get("samesite")}
489
+ for n in nodes if n.type == "cookie"],
490
+ }
491
+ stack = []
492
+ for t in m.stack[:cap]:
493
+ ids = t.get("evidence", [])[:2]
494
+ cited.update(ids)
495
+ stack.append({k: t[k] for k in ("name", "category") if t.get(k)}
496
+ | ({"kind": t["kind"]} if t.get("kind") else {})
497
+ | ({"version": t["version"]} if t.get("version") else {})
498
+ | {"evidence": [f"ev_{i}" for i in ids]})
499
+ top_secrets = sorted(m.secrets, key=lambda s: (-s["count"], s["kind"]))[:20]
500
+ secrets = [{"kind": s["kind"], "length": s["length"], "entropy": s["entropy"],
501
+ "count": s["count"], "evidence": [f"ev_{i}" for i in s["evidence"][:3]]}
502
+ for s in top_secrets]
503
+ for s in top_secrets:
504
+ cited.update(s["evidence"][:3])
505
+
506
+ def cov(shown, total):
507
+ return f"{shown}/{total}" + (" (capped)" if total > shown else "")
508
+
509
+ from .. import __version__
510
+ package = {
511
+ "tool": "proxy2model",
512
+ "version": __version__,
513
+ "app": m.name,
514
+ "lens": lens,
515
+ "rules": CONTEXT_RULES,
516
+ "scope": m.scope,
517
+ "roles": sorted(m.roles),
518
+ "shape": m.counts(),
519
+ "routes": [simple_view(n) for n in routes],
520
+ "endpoints": [endpoint_view(n) for n in endpoints],
521
+ "scripts": [{**simple_view(n), "references": refs_out.get(n.id, 0)} for n in scripts],
522
+ "third_parties": [simple_view(n) for n in tps],
523
+ "stack": stack,
524
+ "trust": trust,
525
+ "secrets": secrets,
526
+ "unknowns": [{"type": u.type,
527
+ "entity": m.nodes[u.entity].label if u.entity in m.nodes
528
+ else u.entity.split(":")[-1],
529
+ "we_know": u.we_know, "we_dont_know": u.we_dont_know,
530
+ "next": u.next_step} for u in unknowns],
531
+ "coverage": {
532
+ "routes": cov(len(routes), len(routes_all)),
533
+ "endpoints": cov(len(endpoints), len(eps_all)),
534
+ "scripts": cov(len(scripts), len(scripts_all)),
535
+ "third_parties": cov(len(tps), len(tps_all)),
536
+ "unknowns": cov(len(unknowns), len(m.unknowns)),
537
+ "capture": {k: m.stats[k] for k in sorted(m.stats)},
538
+ "note": "endpoints are ordered by the lens, so capping drops the least "
539
+ "interesting ones first" if lens != "all" else "model order",
540
+ },
541
+ }
542
+ if graph:
543
+ from .graph import reason_graph
544
+ g = reason_graph(m, cap=max(cap, 60))
545
+ package["graph"] = g
546
+
547
+ def _collect_ev(obj):
548
+ if isinstance(obj, dict):
549
+ for k, v in obj.items():
550
+ if k == "evidence" and isinstance(v, list):
551
+ cited.update(i for i in v if isinstance(i, int))
552
+ else:
553
+ _collect_ev(v)
554
+ elif isinstance(obj, list):
555
+ for v in obj:
556
+ _collect_ev(v)
557
+ _collect_ev(g)
558
+ osint_view = osint_summary(osint)
559
+ if osint_view:
560
+ package["osint"] = osint_view
561
+ package["rules"] = CONTEXT_RULES + [
562
+ "OSINT is external recon, not from the capture; label it as such and don't "
563
+ "conflate a subdomain or open port with an observed endpoint."]
564
+ by_id = {e["id"]: e for e in m.evidence_log.meta()}
565
+ package["evidence"] = {
566
+ f"ev_{i}": {k: by_id[i][k] for k in ("method", "host", "path", "status", "role",
567
+ "source", "item")}
568
+ for i in sorted(cited) if i in by_id
569
+ }
570
+ package["token_estimate"] = len(json.dumps(package, separators=(",", ":"))) // 4
571
+ return package