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
proxy2model/db/bql.py ADDED
@@ -0,0 +1,710 @@
1
+ """
2
+ BQL — the proxy2model query language, run against graph.db.
3
+
4
+ Two kinds of query, both answered from the database with no language model and
5
+ every row carrying the evidence ids (`ev_N`) behind it.
6
+
7
+ Filters — an HTTPQL-style boolean expression over requests, nodes or edges:
8
+
9
+ req.method:POST AND resp.code.gte:400
10
+ req.host.cont:"api." AND NOT role:admin
11
+ resp.body.regex:"stack ?trace" OR resp.header.cont:"x-powered-by"
12
+ node.type:endpoint AND node.state:STATIC_ONLY
13
+ edge.state:EXTERNAL
14
+ "login" (free text, on requests)
15
+
16
+ A term is `field:value` or `field.op:value`. Operators: eq (default), ne, cont,
17
+ ncont, like (`*` wildcard), regex, gt, gte, lt, lte. Combine with AND, OR, NOT
18
+ and parentheses; add `limit N` at the end. The fields (prefix decides the table):
19
+
20
+ requests req.method req.host req.path req.template req.header req.body
21
+ resp.code resp.mime resp.header resp.body role id source
22
+ nodes node.type node.label node.id node.layer node.source node.role
23
+ node.state node.evidence node.attr.<name>
24
+ edges edge.type edge.state edge.src edge.dst edge.src.type edge.dst.type
25
+ edge.src.label edge.dst.label
26
+
27
+ Graph verbs — the structure, walked in the database:
28
+
29
+ reach <node> how it is reached, back to an entry point
30
+ blast <node> [depth N] everything downstream of it
31
+ upstream <node> [depth N] everything that leads into it
32
+ neighbors <node> [depth N]
33
+ path <a> to <b> shortest route between two nodes
34
+ provenance <node> every request that evidenced it
35
+ static-only endpoints named in code that traffic never proved
36
+ changed-since <build> what moved since a build (number, label or `first`)
37
+ reaches-privileged <role> privileged-looking endpoints that role reached
38
+ osint [section] stored external recon (dns, tls, subdomains, ...)
39
+ unknowns | stats | schema | help
40
+ sql <SELECT ...> a read-only SELECT against the tables
41
+
42
+ `<node>` is a node id, an exact label, or part of a label (`POST /api/login`).
43
+ Every value is bound as a SQL parameter, never spliced into the statement.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import json
49
+ import re
50
+ import sqlite3
51
+ from collections import deque
52
+ from dataclasses import dataclass, field
53
+
54
+ from . import store
55
+
56
+ DEFAULT_LIMIT = 100
57
+ MAX_LIMIT = 5000
58
+
59
+ HELP = __doc__
60
+
61
+
62
+ class BQLError(ValueError):
63
+ """A query the language cannot run (bad syntax, unknown field, ambiguity)."""
64
+
65
+
66
+ @dataclass
67
+ class Result:
68
+ kind: str # requests | nodes | edges | paths | facts | table | text
69
+ columns: list[str] = field(default_factory=list)
70
+ rows: list[dict] = field(default_factory=list)
71
+ note: str = ""
72
+ total: int | None = None # rows matched before `limit`
73
+
74
+ def to_dict(self) -> dict:
75
+ return {"kind": self.kind, "columns": self.columns, "rows": self.rows,
76
+ "note": self.note, "total": self.total}
77
+
78
+
79
+ # ----------------------------------------------------------------- lexing ----
80
+
81
+ _OPS = ("eq", "ne", "cont", "ncont", "like", "regex", "gt", "gte", "lt", "lte")
82
+ _TOKEN = re.compile(r"""
83
+ \s*(?:
84
+ (?P<lp>\()|(?P<rp>\))
85
+ | (?P<term>[A-Za-z_][\w.\-]*:(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s()]+))
86
+ | (?P<str>"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')
87
+ | (?P<word>[^\s()"']+)
88
+ )""", re.X)
89
+
90
+
91
+ def _unquote(s: str) -> str:
92
+ if len(s) >= 2 and s[0] == s[-1] and s[0] in "\"'":
93
+ # only the quote itself is escapable; every other backslash is kept, so
94
+ # a regex like "\d+\.\d+" reaches the regex engine exactly as typed
95
+ return s[1:-1].replace("\\" + s[0], s[0])
96
+ return s
97
+
98
+
99
+ def _lex(text: str) -> list[tuple[str, str]]:
100
+ toks, pos = [], 0
101
+ text = text.strip()
102
+ while pos < len(text):
103
+ m = _TOKEN.match(text, pos)
104
+ if not m or m.end() == pos:
105
+ raise BQLError(f"cannot read the query near {text[pos:pos + 20]!r}")
106
+ pos = m.end()
107
+ kind = m.lastgroup
108
+ val = m.group(kind)
109
+ if kind == "word" and val.upper() in ("AND", "OR", "NOT"):
110
+ toks.append((val.upper(), val.upper()))
111
+ elif kind == "term":
112
+ head, _, raw = val.partition(":")
113
+ toks.append(("term", (head, _unquote(raw))))
114
+ elif kind == "str":
115
+ toks.append(("text", _unquote(val)))
116
+ elif kind in ("lp", "rp"):
117
+ toks.append((kind, val))
118
+ else:
119
+ toks.append(("word", val))
120
+ return toks
121
+
122
+
123
+ # ---------------------------------------------------------------- parsing ----
124
+
125
+ # AST: ("and", a, b) ("or", a, b) ("not", a) ("term", field, op, value) ("text", value)
126
+
127
+ class _Parser:
128
+ def __init__(self, toks):
129
+ self.t, self.i = toks, 0
130
+
131
+ def peek(self):
132
+ return self.t[self.i][0] if self.i < len(self.t) else None
133
+
134
+ def take(self):
135
+ tok = self.t[self.i]
136
+ self.i += 1
137
+ return tok
138
+
139
+ def parse(self):
140
+ node = self.or_()
141
+ if self.i < len(self.t):
142
+ raise BQLError(f"unexpected {self.t[self.i][1]!r}")
143
+ return node
144
+
145
+ def or_(self):
146
+ node = self.and_()
147
+ while self.peek() == "OR":
148
+ self.take()
149
+ node = ("or", node, self.and_())
150
+ return node
151
+
152
+ def and_(self):
153
+ node = self.not_()
154
+ while self.peek() in ("AND", "term", "text", "lp", "NOT"): # adjacent = AND
155
+ if self.peek() == "AND":
156
+ self.take()
157
+ node = ("and", node, self.not_())
158
+ return node
159
+
160
+ def not_(self):
161
+ if self.peek() == "NOT":
162
+ self.take()
163
+ return ("not", self.not_())
164
+ return self.atom()
165
+
166
+ def atom(self):
167
+ kind = self.peek()
168
+ if kind == "lp":
169
+ self.take()
170
+ node = self.or_()
171
+ if self.peek() != "rp":
172
+ raise BQLError("missing )")
173
+ self.take()
174
+ return node
175
+ if kind == "term":
176
+ _, (head, raw) = self.take()
177
+ parts = head.split(".")
178
+ op = "eq"
179
+ if len(parts) > 1 and parts[-1] in _OPS:
180
+ op = parts.pop()
181
+ return ("term", ".".join(parts), op, raw)
182
+ if kind == "text":
183
+ return ("text", self.take()[1])
184
+ raise BQLError("expected a term like field:value" if kind is None
185
+ else f"unexpected {self.t[self.i][1]!r}")
186
+
187
+
188
+ # -------------------------------------------------------------- compiling ----
189
+
190
+ # field -> (sql expression, kind) kind: text | int
191
+ _REQUEST_FIELDS = {
192
+ "req.method": ("method", "text"), "req.host": ("host", "text"),
193
+ "req.path": ("path", "text"), "req.template": ("path_template", "text"),
194
+ "req.header": ("req_headers", "text"), "req.body": ("req_body", "text"),
195
+ "resp.code": ("status", "int"), "resp.mime": ("mime", "text"),
196
+ "resp.header": ("resp_headers", "text"), "resp.body": ("resp_body", "text"),
197
+ "role": ("role", "text"), "id": ("id", "int"), "source": ("source", "text"),
198
+ }
199
+ _REQUEST_TEXT = ("path", "req_body", "resp_body", "req_headers", "resp_headers", "host")
200
+
201
+ _NODE_FIELDS = {
202
+ "node.id": ("nodes.id", "text"), "node.type": ("nodes.type", "text"),
203
+ "node.label": ("nodes.label", "text"), "node.layer": ("nodes.layer", "int"),
204
+ "node.source": ("nodes.source", "text"), "node.role": ("nodes.roles", "text"),
205
+ "node.state": ("CAST(json_extract(nodes.attrs,'$.api_state') AS TEXT)", "text"),
206
+ # history across builds (CLI only; the report holds one build)
207
+ "node.first_seen": ("(SELECT first_seen FROM entity_history h WHERE h.kind='node' AND h.key=nodes.id)", "text"),
208
+ "node.last_seen": ("(SELECT last_seen FROM entity_history h WHERE h.kind='node' AND h.key=nodes.id)", "text"),
209
+ "node.first_build": ("(SELECT first_build FROM entity_history h WHERE h.kind='node' AND h.key=nodes.id)", "int"),
210
+ "node.last_build": ("(SELECT last_build FROM entity_history h WHERE h.kind='node' AND h.key=nodes.id)", "int"),
211
+ }
212
+ _NODE_TEXT = ("nodes.label", "nodes.id")
213
+
214
+ _EDGE_FIELDS = {
215
+ "edge.type": ("edges.type", "text"), "edge.state": ("edges.state", "text"),
216
+ "edge.src": ("edges.src", "text"), "edge.dst": ("edges.dst", "text"),
217
+ "edge.src.type": ("(SELECT type FROM nodes WHERE id=edges.src)", "text"),
218
+ "edge.dst.type": ("(SELECT type FROM nodes WHERE id=edges.dst)", "text"),
219
+ "edge.src.label": ("(SELECT label FROM nodes WHERE id=edges.src)", "text"),
220
+ "edge.dst.label": ("(SELECT label FROM nodes WHERE id=edges.dst)", "text"),
221
+ }
222
+ _EDGE_TEXT = ("edges.src", "edges.dst", "edges.type")
223
+
224
+ _ATTR = re.compile(r"^[A-Za-z_][\w\-]*$")
225
+
226
+
227
+ def _target_of(field_name: str) -> str:
228
+ if field_name in _REQUEST_FIELDS:
229
+ return "requests"
230
+ if field_name in _EDGE_FIELDS:
231
+ return "edges"
232
+ if field_name in _NODE_FIELDS or field_name == "node.evidence" \
233
+ or field_name.startswith("node.attr."):
234
+ return "nodes"
235
+ raise BQLError(f"unknown field {field_name!r}; run `help` for the field list")
236
+
237
+
238
+ def _targets(ast) -> set[str]:
239
+ kind = ast[0]
240
+ if kind == "term":
241
+ return {_target_of(ast[1])}
242
+ if kind == "text":
243
+ return set()
244
+ return set().union(*(_targets(a) for a in ast[1:]))
245
+
246
+
247
+ def _esc_like(s: str) -> str:
248
+ return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
249
+
250
+
251
+ def _term_sql(target: str, fname: str, op: str, value: str, params: list) -> str:
252
+ if target == "requests":
253
+ col, kind = _REQUEST_FIELDS[fname]
254
+ elif target == "edges":
255
+ col, kind = _EDGE_FIELDS[fname]
256
+ elif fname == "node.evidence":
257
+ try:
258
+ ev = int(re.sub(r"^(?:ev_|evd[ _]?)", "", value.lower()))
259
+ except ValueError:
260
+ raise BQLError(f"node.evidence needs an evidence id, got {value!r}")
261
+ if op not in ("eq", "ne"):
262
+ raise BQLError("node.evidence supports eq and ne")
263
+ params.append(ev)
264
+ inner = "nodes.id IN (SELECT node_id FROM node_evidence WHERE ev_id=?)"
265
+ return inner if op == "eq" else f"NOT ({inner})"
266
+ elif fname.startswith("node.attr."):
267
+ attr = fname[len("node.attr."):]
268
+ if not _ATTR.match(attr):
269
+ raise BQLError(f"bad attribute name {attr!r}")
270
+ col, kind = f"CAST(json_extract(nodes.attrs,'$.{attr}') AS TEXT)", "text"
271
+ else:
272
+ col, kind = _NODE_FIELDS[fname]
273
+
274
+ if fname == "node.role" and op in ("eq", "ne"):
275
+ params.append(f'%"{_esc_like(value)}"%')
276
+ return ("nodes.roles LIKE ? ESCAPE '\\'" if op == "eq"
277
+ else "nodes.roles NOT LIKE ? ESCAPE '\\'")
278
+
279
+ if kind == "int":
280
+ if op in ("cont", "ncont", "like", "regex"):
281
+ raise BQLError(f"{fname} is numeric; use eq ne gt gte lt lte")
282
+ try:
283
+ params.append(int(value))
284
+ except ValueError:
285
+ raise BQLError(f"{fname} needs a number, got {value!r}")
286
+ sym = {"eq": "=", "ne": "!=", "gt": ">", "gte": ">=", "lt": "<", "lte": "<="}[op]
287
+ return f"{col} {sym} ?"
288
+
289
+ if op in ("gt", "gte", "lt", "lte"):
290
+ raise BQLError(f"{fname} is text; {op} is for numbers")
291
+ if op == "eq":
292
+ params.append(value)
293
+ return f"{col} = ? COLLATE NOCASE"
294
+ if op == "ne":
295
+ params.append(value)
296
+ return f"({col} IS NULL OR {col} != ? COLLATE NOCASE)"
297
+ if op in ("cont", "ncont"):
298
+ params.append(f"%{_esc_like(value)}%")
299
+ return (f"{col} LIKE ? ESCAPE '\\'" if op == "cont"
300
+ else f"({col} IS NULL OR {col} NOT LIKE ? ESCAPE '\\')")
301
+ if op == "like":
302
+ params.append("%".join(_esc_like(p) for p in value.split("*")))
303
+ return f"{col} LIKE ? ESCAPE '\\'"
304
+ try:
305
+ re.compile(value)
306
+ except re.error as e:
307
+ raise BQLError(f"bad regex {value!r}: {e}")
308
+ params.append(value)
309
+ return f"{col} REGEXP ?"
310
+
311
+
312
+ def _compile(ast, target: str, params: list) -> str:
313
+ kind = ast[0]
314
+ if kind == "and":
315
+ return f"({_compile(ast[1], target, params)} AND {_compile(ast[2], target, params)})"
316
+ if kind == "or":
317
+ return f"({_compile(ast[1], target, params)} OR {_compile(ast[2], target, params)})"
318
+ if kind == "not":
319
+ return f"(NOT {_compile(ast[1], target, params)})"
320
+ if kind == "text":
321
+ cols = {"requests": _REQUEST_TEXT, "nodes": _NODE_TEXT, "edges": _EDGE_TEXT}[target]
322
+ params.extend([f"%{_esc_like(ast[1])}%"] * len(cols))
323
+ return "(" + " OR ".join(f"{c} LIKE ? ESCAPE '\\'" for c in cols) + ")"
324
+ return _term_sql(target, ast[1], ast[2], ast[3], params)
325
+
326
+
327
+ # --------------------------------------------------------------- connection --
328
+
329
+ def _regexp(pattern, value):
330
+ if value is None:
331
+ return False
332
+ try:
333
+ return re.search(pattern, value, re.I) is not None
334
+ except re.error:
335
+ return False
336
+
337
+
338
+ def connect(path: str) -> sqlite3.Connection:
339
+ """Open graph.db for querying: read-only semantics plus the REGEXP function."""
340
+ conn = store.open_db(path)
341
+ conn.create_function("regexp", 2, _regexp, deterministic=True)
342
+ conn.execute("PRAGMA query_only = ON")
343
+ try:
344
+ conn.execute("SELECT json_extract('{}','$.a')")
345
+ except sqlite3.OperationalError:
346
+ raise BQLError("this Python's SQLite lacks the JSON1 extension BQL needs")
347
+ return conn
348
+
349
+
350
+ # ---------------------------------------------------------------- filters ----
351
+
352
+ _LIMIT = re.compile(r"\s+limit\s+(\d+)\s*$", re.I)
353
+
354
+ _REQ_SELECT = ("SELECT id, method, status, host, path, role, mime FROM exchanges")
355
+ _NODE_SELECT = ("SELECT id, type, layer, label, source, roles, attrs, "
356
+ "(SELECT group_concat(ev_id) FROM node_evidence WHERE node_id=nodes.id) AS ev "
357
+ "FROM nodes")
358
+ _EDGE_SELECT = ("SELECT edges.id AS id, "
359
+ "(SELECT label FROM nodes WHERE id=edges.src) AS src, edges.type AS type, "
360
+ "edges.state AS state, (SELECT label FROM nodes WHERE id=edges.dst) AS dst, "
361
+ "edges.src AS src_id, edges.dst AS dst_id, "
362
+ "(SELECT group_concat(ev_id) FROM edge_evidence WHERE edge_id=edges.id) AS ev "
363
+ "FROM edges")
364
+
365
+
366
+ def _evs(csv) -> list[int]:
367
+ return sorted({int(x) for x in str(csv).split(",") if x}) if csv else []
368
+
369
+
370
+ def run_filter(conn: sqlite3.Connection, text: str, limit: int = DEFAULT_LIMIT) -> Result:
371
+ m = _LIMIT.search(text)
372
+ if m:
373
+ limit = int(m.group(1))
374
+ text = text[:m.start()]
375
+ limit = max(1, min(limit, MAX_LIMIT))
376
+ ast = _Parser(_lex(text)).parse()
377
+ targets = _targets(ast)
378
+ if len(targets) > 1:
379
+ raise BQLError("a query can filter one of requests, nodes or edges — "
380
+ f"this mixes {', '.join(sorted(targets))}")
381
+ target = next(iter(targets), "requests")
382
+ params: list = []
383
+ where = _compile(ast, target, params)
384
+ table = {"requests": "exchanges", "nodes": "nodes", "edges": "edges"}[target]
385
+ total = conn.execute(f"SELECT count(*) FROM {table} WHERE {where}", params).fetchone()[0]
386
+
387
+ if target == "requests":
388
+ rows = conn.execute(f"{_REQ_SELECT} WHERE {where} ORDER BY id LIMIT ?",
389
+ params + [limit]).fetchall()
390
+ out = [{"ev": f"ev_{r['id']}", "method": r["method"], "status": r["status"],
391
+ "host": r["host"], "path": r["path"], "role": r["role"] or "",
392
+ "mime": r["mime"] or ""} for r in rows]
393
+ cols = ["ev", "method", "status", "host", "path", "role"]
394
+ elif target == "nodes":
395
+ rows = conn.execute(f"{_NODE_SELECT} WHERE {where} ORDER BY layer, type, label LIMIT ?",
396
+ params + [limit]).fetchall()
397
+ out = []
398
+ for r in rows:
399
+ attrs = json.loads(r["attrs"])
400
+ out.append({"id": r["id"], "type": r["type"], "layer": r["layer"],
401
+ "label": r["label"], "source": r["source"],
402
+ "state": attrs.get("api_state") or "",
403
+ "roles": ",".join(json.loads(r["roles"])),
404
+ "evidence": [f"ev_{i}" for i in _evs(r["ev"])]})
405
+ cols = ["type", "label", "state", "source", "roles", "evidence"]
406
+ else:
407
+ rows = conn.execute(f"{_EDGE_SELECT} WHERE {where} ORDER BY edges.id LIMIT ?",
408
+ params + [limit]).fetchall()
409
+ out = [{"src": r["src"], "type": r["type"], "state": r["state"], "dst": r["dst"],
410
+ "evidence": [f"ev_{i}" for i in _evs(r["ev"])]} for r in rows]
411
+ cols = ["src", "type", "state", "dst", "evidence"]
412
+ note = f"{total} match{'es' if total != 1 else ''}"
413
+ if total > len(out):
414
+ note += f" (showing {len(out)}; add `limit N`)"
415
+ return Result(target, cols, out, note, total)
416
+
417
+
418
+ # ------------------------------------------------------------ graph verbs ----
419
+
420
+ def resolve(conn: sqlite3.Connection, ref: str) -> str:
421
+ """Turn an id, exact label or label fragment into one node id."""
422
+ ref = ref.strip()
423
+ row = conn.execute("SELECT id FROM nodes WHERE id=?", (ref,)).fetchone()
424
+ if row:
425
+ return row[0]
426
+ rows = conn.execute("SELECT id FROM nodes WHERE label=? COLLATE NOCASE", (ref,)).fetchall()
427
+ if not rows:
428
+ rows = conn.execute("SELECT id FROM nodes WHERE label LIKE ? ESCAPE '\\' "
429
+ "ORDER BY layer LIMIT 6", (f"%{_esc_like(ref)}%",)).fetchall()
430
+ if not rows:
431
+ raise BQLError(f"no node matches {ref!r}")
432
+ if len(rows) > 1:
433
+ labels = [conn.execute("SELECT label FROM nodes WHERE id=?", (r[0],)).fetchone()[0]
434
+ for r in rows[:5]]
435
+ raise BQLError(f"{ref!r} is ambiguous — be more specific: " + " | ".join(labels))
436
+ return rows[0][0]
437
+
438
+
439
+ def _label(conn, nid: str) -> str:
440
+ r = conn.execute("SELECT label FROM nodes WHERE id=?", (nid,)).fetchone()
441
+ return r[0] if r else nid
442
+
443
+
444
+ def _edge_evs(conn, edge_id: int) -> list[str]:
445
+ return [f"ev_{r[0]}" for r in conn.execute(
446
+ "SELECT ev_id FROM edge_evidence WHERE edge_id=? ORDER BY ev_id", (edge_id,))]
447
+
448
+
449
+ def _walk(conn, nid: str, depth: int, direction: str) -> Result:
450
+ """Recursive-CTE traversal; `out` follows edges forward, `in` backward."""
451
+ a, b = ("src", "dst") if direction == "out" else ("dst", "src")
452
+ depth = max(1, min(depth, 8))
453
+ rows = conn.execute(f"""
454
+ WITH RECURSIVE walk(id, d) AS (
455
+ SELECT ?, 0
456
+ UNION
457
+ SELECT e.{b}, walk.d + 1 FROM walk JOIN edges e ON e.{a} = walk.id
458
+ WHERE walk.d < ?
459
+ )
460
+ SELECT n.id, n.type, n.label, n.source, min(walk.d) AS d
461
+ FROM walk JOIN nodes n ON n.id = walk.id
462
+ WHERE walk.d > 0 GROUP BY n.id ORDER BY d, n.type, n.label""", (nid, depth)).fetchall()
463
+ out = [{"hops": r["d"], "type": r["type"], "label": r["label"], "source": r["source"]}
464
+ for r in rows]
465
+ word = "downstream of" if direction == "out" else "upstream of"
466
+ return Result("nodes", ["hops", "type", "label", "source"], out,
467
+ f"{len(out)} node(s) {word} {_label(conn, nid)} (depth {depth})", len(out))
468
+
469
+
470
+ def _paths(conn, start: str, goal: str | None, limit: int = 3, max_depth: int = 8) -> list[list[dict]]:
471
+ """Shortest paths. goal=None walks backwards from `start` to an entry host."""
472
+ back = goal is None
473
+ a, b = ("dst", "src") if back else ("src", "dst")
474
+ found: list[list[dict]] = []
475
+ seen = {start: 0}
476
+ queue = deque([(start, [])])
477
+ while queue and len(found) < limit:
478
+ cur, trail = queue.popleft()
479
+ if len(trail) >= max_depth:
480
+ continue
481
+ for e in conn.execute(
482
+ f"SELECT id, src, dst, type, state FROM edges WHERE {a}=? ORDER BY id", (cur,)):
483
+ nxt = e[b]
484
+ hop = {"from": e["src"], "to": e["dst"], "edge": e["type"], "state": e["state"],
485
+ "evidence": _edge_evs(conn, e["id"])}
486
+ path = trail + [hop]
487
+ if (back and conn.execute("SELECT type FROM nodes WHERE id=?", (nxt,)).fetchone()[0]
488
+ == "host") or (not back and nxt == goal):
489
+ found.append(path)
490
+ if len(found) >= limit:
491
+ break
492
+ continue
493
+ if nxt not in seen or seen[nxt] >= len(path):
494
+ seen[nxt] = len(path)
495
+ queue.append((nxt, path))
496
+ if back:
497
+ found = [list(reversed(p)) for p in found]
498
+ return found
499
+
500
+
501
+ def _paths_result(conn, paths, title: str) -> Result:
502
+ rows = []
503
+ for n, p in enumerate(paths, 1):
504
+ for h, hop in enumerate(p, 1):
505
+ rows.append({"path": n, "hop": h, "from": _label(conn, hop["from"]),
506
+ "edge": hop["edge"], "state": hop["state"],
507
+ "to": _label(conn, hop["to"]), "evidence": hop["evidence"]})
508
+ return Result("paths", ["path", "hop", "from", "edge", "state", "to", "evidence"], rows,
509
+ f"{len(paths)} path(s): {title}" if paths else f"no path: {title}",
510
+ len(paths))
511
+
512
+
513
+ def _depth_arg(words: list[str]) -> tuple[list[str], int]:
514
+ if len(words) >= 2 and words[-2].lower() == "depth" and words[-1].isdigit():
515
+ return words[:-2], int(words[-1])
516
+ return words, 3
517
+
518
+
519
+ def _osint_result(conn, section: str | None) -> Result:
520
+ row = conn.execute("SELECT id, host, generated_at FROM osint_runs ORDER BY id DESC LIMIT 1"
521
+ ).fetchone()
522
+ if not row:
523
+ return Result("facts", ["section", "key", "value"], [],
524
+ "no OSINT stored yet — build with OSINT enabled (the default)", 0)
525
+ q = "SELECT section, key, value FROM osint_facts WHERE run_id=?"
526
+ p: list = [row["id"]]
527
+ if section:
528
+ q += " AND section=? COLLATE NOCASE"
529
+ p.append(section)
530
+ rows = [{"section": r[0], "key": r[1], "value": r[2]}
531
+ for r in conn.execute(q + " ORDER BY rowid", p)]
532
+ return Result("facts", ["section", "key", "value"], rows,
533
+ f"OSINT run #{row['id']} · {row['host']} · {row['generated_at']}", len(rows))
534
+
535
+
536
+ def _provenance(conn, nid: str) -> Result:
537
+ rows = [{"ev": f"ev_{r['id']}", "role": r["role"] or "", "method": r["method"],
538
+ "url": f"{r['host']}{r['path']}", "status": r["status"], "source": r["source"]}
539
+ for r in conn.execute(
540
+ "SELECT x.* FROM node_evidence ne JOIN exchanges x ON x.id=ne.ev_id "
541
+ "WHERE ne.node_id=? ORDER BY x.id", (nid,))]
542
+ return Result("table", ["ev", "role", "method", "url", "status", "source"], rows,
543
+ f"{len(rows)} request(s) behind {_label(conn, nid)}", len(rows))
544
+
545
+
546
+ def _static_only(conn) -> Result:
547
+ rows = []
548
+ for r in conn.execute("SELECT id, label, attrs FROM nodes WHERE type='endpoint' ORDER BY label"):
549
+ if json.loads(r["attrs"] or "{}").get("api_state") != "STATIC_ONLY":
550
+ continue
551
+ by = [x[0] for x in conn.execute(
552
+ "SELECT n.label FROM edges e JOIN nodes n ON n.id=e.src WHERE e.dst=? AND e.type='REFERENCES' "
553
+ "ORDER BY n.label", (r["id"],))]
554
+ ev = [f"ev_{x[0]}" for x in conn.execute(
555
+ "SELECT ev_id FROM node_evidence WHERE node_id=? ORDER BY ev_id", (r["id"],))]
556
+ rows.append({"endpoint": r["label"], "named_in": by, "evidence": ev})
557
+ return Result("table", ["endpoint", "named_in", "evidence"], rows,
558
+ f"{len(rows)} endpoint(s) in code, never seen in traffic", len(rows))
559
+
560
+
561
+ def _changed_since(conn, ref: str) -> Result:
562
+ from ..core import diff as diffmod
563
+ from ..core.model import from_dict
564
+ try:
565
+ old = store.resolve_build(conn, _unquote(ref))
566
+ new = store.resolve_build(conn, "latest")
567
+ except ValueError as e:
568
+ raise BQLError(str(e))
569
+ if old == new:
570
+ raise BQLError(f"build #{old} is the latest; nothing to compare yet")
571
+ snaps = []
572
+ for bid in (old, new):
573
+ try:
574
+ snap = store.load_build(conn, bid)
575
+ except ValueError as e:
576
+ raise BQLError(str(e))
577
+ row = conn.execute("SELECT built_at, label FROM builds WHERE id=?", (bid,)).fetchone()
578
+ snaps.append((from_dict(snap), {"id": bid, "built_at": row["built_at"], "label": row["label"] or ""}))
579
+ d = diffmod.diff_models(snaps[0][0], snaps[1][0], snaps[0][1], snaps[1][1],
580
+ hist=lambda k, key: store.history(conn, k, key))
581
+ return Result("text", note=diffmod.format_text(d))
582
+
583
+
584
+ def _reaches_privileged(conn, role: str) -> Result:
585
+ from ..core.model import PRIV_RE
586
+ rows = []
587
+ for r in conn.execute("SELECT id, label, attrs, roles FROM nodes WHERE type='endpoint' ORDER BY label"):
588
+ a = json.loads(r["attrs"] or "{}")
589
+ if role.lower() not in [x.lower() for x in json.loads(r["roles"] or "[]")]:
590
+ continue
591
+ if not PRIV_RE.search(a.get("path", "") or ""):
592
+ continue
593
+ sbr = (a.get("status_by_role") or {})
594
+ sbr = next((v for k, v in sbr.items() if k.lower() == role.lower()), [])
595
+ ev = [f"ev_{x[0]}" for x in conn.execute(
596
+ "SELECT ev_id FROM node_evidence WHERE node_id=? ORDER BY ev_id LIMIT 3", (r["id"],))]
597
+ p = _paths(conn, r["id"], None, limit=1)
598
+ via = " → ".join([_label(conn, p[0][0]["from"])] + [_label(conn, h["to"]) for h in p[0]]) if p else ""
599
+ rows.append({"endpoint": r["label"], "statuses": sbr, "via": via, "evidence": ev})
600
+ return Result("table", ["endpoint", "statuses", "via", "evidence"], rows,
601
+ f"{len(rows)} privileged-looking endpoint(s) reached as {role}; "
602
+ "check each response was meant for that role", len(rows))
603
+
604
+
605
+ def run_query(conn: sqlite3.Connection, text: str, limit: int = DEFAULT_LIMIT) -> Result:
606
+ """Run one BQL statement. Raises BQLError on anything it cannot run."""
607
+ text = (text or "").strip().rstrip(";").strip()
608
+ if not text:
609
+ raise BQLError("empty query — try `help`")
610
+ first, _, rest = text.partition(" ")
611
+ verb, rest = first.lower(), rest.strip()
612
+
613
+ if verb == "help":
614
+ return Result("text", note=HELP)
615
+ if verb == "schema":
616
+ rows = []
617
+ for t in store.table_names(conn):
618
+ cols = [c[1] for c in conn.execute(f"PRAGMA table_info({t})")]
619
+ rows.append({"table": t, "columns": ", ".join(cols)})
620
+ return Result("table", ["table", "columns"], rows, f"{len(rows)} tables", len(rows))
621
+ if verb == "stats":
622
+ s = store.summary(conn)
623
+ return Result("table", ["metric", "value"],
624
+ [{"metric": k, "value": v} for k, v in s.items()], "graph.db", len(s))
625
+ if verb == "unknowns":
626
+ rows = [{"type": r["type"], "entity": r["entity"], "we_dont_know": r["we_dont_know"],
627
+ "next_step": r["next_step"]}
628
+ for r in conn.execute("SELECT * FROM unknowns ORDER BY id")]
629
+ return Result("table", ["type", "entity", "we_dont_know", "next_step"], rows,
630
+ f"{len(rows)} unknown(s)", len(rows))
631
+ if verb == "osint":
632
+ return _osint_result(conn, rest or None)
633
+ if verb == "sql":
634
+ try:
635
+ rows = store.read_only_sql(conn, rest)
636
+ except (ValueError, sqlite3.Error) as e:
637
+ raise BQLError(f"sql: {e}")
638
+ cols = list(rows[0].keys()) if rows else []
639
+ return Result("table", cols, [dict(r) for r in rows[:limit]],
640
+ f"{len(rows)} row(s)", len(rows))
641
+ if verb == "provenance":
642
+ if not rest:
643
+ raise BQLError("provenance needs a node")
644
+ return _provenance(conn, resolve(conn, _unquote(rest)))
645
+ if verb == "static-only":
646
+ return _static_only(conn)
647
+ if verb == "changed-since":
648
+ return _changed_since(conn, rest or "previous")
649
+ if verb == "reaches-privileged":
650
+ if not rest:
651
+ raise BQLError("reaches-privileged needs a role")
652
+ return _reaches_privileged(conn, _unquote(rest))
653
+ if verb in ("reach", "blast", "upstream", "neighbors", "path"):
654
+ if not rest:
655
+ raise BQLError(f"{verb} needs a node")
656
+ if verb == "path":
657
+ m = re.split(r"\s+to\s+", rest, maxsplit=1, flags=re.I)
658
+ if len(m) != 2:
659
+ raise BQLError("usage: path <a> to <b>")
660
+ a, b = resolve(conn, _unquote(m[0].strip())), resolve(conn, _unquote(m[1].strip()))
661
+ return _paths_result(conn, _paths(conn, a, b, limit=1),
662
+ f"{_label(conn, a)} → {_label(conn, b)}")
663
+ words, depth = _depth_arg(rest.split())
664
+ nid = resolve(conn, _unquote(" ".join(words)))
665
+ if verb == "reach":
666
+ return _paths_result(conn, _paths(conn, nid, None),
667
+ f"how {_label(conn, nid)} is reached")
668
+ if verb == "blast":
669
+ return _walk(conn, nid, depth, "out")
670
+ if verb == "upstream":
671
+ return _walk(conn, nid, depth, "in")
672
+ down, up = _walk(conn, nid, depth, "out"), _walk(conn, nid, depth, "in")
673
+ rows = ([dict(r, direction="out") for r in down.rows]
674
+ + [dict(r, direction="in") for r in up.rows])
675
+ return Result("nodes", ["direction", "hops", "type", "label", "source"], rows,
676
+ f"{len(rows)} node(s) within {depth} hop(s) of {_label(conn, nid)}",
677
+ len(rows))
678
+ return run_filter(conn, text, limit)
679
+
680
+
681
+ # ------------------------------------------------------------- rendering ----
682
+
683
+ def _cell(v) -> str:
684
+ if isinstance(v, list):
685
+ return ",".join(map(str, v[:4])) + ("…" if len(v) > 4 else "")
686
+ return "" if v is None else str(v)
687
+
688
+
689
+ def render_text(res: Result, width: int = 44) -> str:
690
+ if res.kind == "text":
691
+ return res.note
692
+ lines = []
693
+ if res.rows:
694
+ cols = res.columns
695
+ cells = [[_cell(r.get(c)) for c in cols] for r in res.rows]
696
+ widths = [min(max(len(c), *(len(row[i]) for row in cells)), width)
697
+ for i, c in enumerate(cols)]
698
+ fmt = lambda row: " ".join(
699
+ (v if len(v) <= w else v[:w - 1] + "…").ljust(w) for v, w in zip(row, widths)
700
+ ).rstrip()
701
+ lines.append(fmt(cols))
702
+ lines.append(fmt(["-" * w for w in widths]))
703
+ lines.extend(fmt(row) for row in cells)
704
+ lines.append("")
705
+ lines.append(res.note)
706
+ return "\n".join(lines)
707
+
708
+
709
+ def render_json(res: Result) -> str:
710
+ return json.dumps(res.to_dict(), indent=2, default=str)