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,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
|