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,547 @@
1
+ """
2
+ The graph database: one SQLite file (`graph.db`) holding the whole web-app model.
3
+
4
+ SQLite is in the standard library, so the package stays zero-dependency, and the
5
+ result is a single portable file anyone can open with any SQLite client. The
6
+ model graph is stored as a property graph:
7
+
8
+ nodes / edges the six-layer graph plus external (OSINT) nodes
9
+ node_evidence / edge_evidence which request (ev_N) backs each node and edge
10
+ exchanges every captured request/response, redacted
11
+ unknowns / secrets named blind spots, secret fingerprints (never values)
12
+ osint_runs / osint_facts external recon, timestamped, one row per run
13
+ builds a compressed model snapshot per build, for drift
14
+
15
+ Only redacted data ever reaches this file: it is loaded from the same masked
16
+ `Model` that `model.json` is written from. `osint` edges carry the state
17
+ EXTERNAL so recon is never confused with OBSERVED traffic or INFERRED code refs.
18
+
19
+ The graph tables are rebuilt on every build; the OSINT and build-history tables
20
+ are kept, so recon accumulates and drift can be diffed over time.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import gzip
26
+ import io
27
+ import json
28
+ import os
29
+ import re
30
+ import sqlite3
31
+ import zlib
32
+ from datetime import datetime, timezone
33
+
34
+ from ..atomic import keep_aside
35
+ from ..core.model import Model, dump_model
36
+
37
+ DB_SCHEMA_VERSION = 2
38
+ DB_NAME = "graph.db"
39
+ # Builds are kept forever so drift can be diffed over any span. Set PROXY2MODEL_KEEP_BUILDS=N to keep
40
+ # only the newest N (a very large model costs a compressed copy per build).
41
+
42
+ _GRAPH_TABLES = ("nodes", "edges", "node_evidence", "edge_evidence",
43
+ "exchanges", "unknowns", "secrets")
44
+
45
+ SCHEMA = """
46
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
47
+ CREATE TABLE IF NOT EXISTS nodes (
48
+ id TEXT PRIMARY KEY, type TEXT NOT NULL, layer INTEGER, label TEXT,
49
+ source TEXT NOT NULL DEFAULT 'traffic', -- traffic | osint
50
+ attrs TEXT NOT NULL DEFAULT '{}', roles TEXT NOT NULL DEFAULT '[]'
51
+ );
52
+ CREATE TABLE IF NOT EXISTS edges (
53
+ id INTEGER PRIMARY KEY, src TEXT NOT NULL, dst TEXT NOT NULL,
54
+ type TEXT NOT NULL, state TEXT NOT NULL -- OBSERVED | INFERRED | EXTERNAL
55
+ );
56
+ CREATE TABLE IF NOT EXISTS node_evidence (node_id TEXT NOT NULL, ev_id INTEGER NOT NULL);
57
+ CREATE TABLE IF NOT EXISTS edge_evidence (edge_id INTEGER NOT NULL, ev_id INTEGER NOT NULL);
58
+ CREATE TABLE IF NOT EXISTS exchanges (
59
+ id INTEGER PRIMARY KEY, source TEXT, item INTEGER, role TEXT,
60
+ method TEXT, host TEXT, path TEXT, path_template TEXT, status INTEGER, mime TEXT,
61
+ req_line TEXT, req_headers TEXT, req_body TEXT, req_truncated INTEGER,
62
+ resp_line TEXT, resp_headers TEXT, resp_body TEXT, resp_truncated INTEGER
63
+ );
64
+ CREATE TABLE IF NOT EXISTS unknowns (
65
+ id INTEGER PRIMARY KEY, type TEXT, entity TEXT, we_know TEXT,
66
+ we_dont_know TEXT, next_step TEXT
67
+ );
68
+ CREATE TABLE IF NOT EXISTS secrets (
69
+ kind TEXT, hmac_12 TEXT, length INTEGER, entropy REAL, count INTEGER, evidence TEXT
70
+ );
71
+ CREATE TABLE IF NOT EXISTS osint_runs (
72
+ id INTEGER PRIMARY KEY, host TEXT, domain TEXT, generated_at TEXT, data TEXT
73
+ );
74
+ CREATE TABLE IF NOT EXISTS osint_facts (
75
+ run_id INTEGER NOT NULL, section TEXT, key TEXT, value TEXT
76
+ );
77
+ CREATE TABLE IF NOT EXISTS builds (
78
+ id INTEGER PRIMARY KEY, built_at TEXT, nodes INTEGER, edges INTEGER,
79
+ exchanges INTEGER, osint_run INTEGER, model_gz BLOB, label TEXT
80
+ );
81
+ -- when each node and edge was first and last in a build; never rewritten, so it spans every run
82
+ CREATE TABLE IF NOT EXISTS entity_history (
83
+ kind TEXT NOT NULL, key TEXT NOT NULL, -- node: its id; edge: src|type|dst
84
+ first_build INTEGER, last_build INTEGER, first_seen TEXT, last_seen TEXT,
85
+ PRIMARY KEY (kind, key)
86
+ );
87
+ CREATE INDEX IF NOT EXISTS ix_nodes_type ON nodes(type);
88
+ CREATE INDEX IF NOT EXISTS ix_edges_src ON edges(src, type);
89
+ CREATE INDEX IF NOT EXISTS ix_edges_dst ON edges(dst, type);
90
+ CREATE INDEX IF NOT EXISTS ix_ne_node ON node_evidence(node_id);
91
+ CREATE INDEX IF NOT EXISTS ix_ee_edge ON edge_evidence(edge_id);
92
+ CREATE INDEX IF NOT EXISTS ix_ex_host ON exchanges(host, method, status);
93
+ CREATE INDEX IF NOT EXISTS ix_ex_path ON exchanges(path_template);
94
+ CREATE INDEX IF NOT EXISTS ix_facts_run ON osint_facts(run_id, section);
95
+ """
96
+
97
+
98
+ def _now() -> str:
99
+ """UTC build time. SOURCE_DATE_EPOCH pins it, so a reproducible build is byte-identical."""
100
+ epoch = os.environ.get("SOURCE_DATE_EPOCH", "")
101
+ when = (datetime.fromtimestamp(int(epoch), timezone.utc) if epoch.isdigit()
102
+ else datetime.now(timezone.utc))
103
+ return when.strftime("%Y-%m-%dT%H:%M:%SZ")
104
+
105
+
106
+ def connect(path: str) -> sqlite3.Connection:
107
+ conn = sqlite3.connect(path)
108
+ conn.row_factory = sqlite3.Row
109
+ return conn
110
+
111
+
112
+ def _init(conn: sqlite3.Connection) -> None:
113
+ conn.executescript(SCHEMA)
114
+ row = conn.execute("SELECT value FROM meta WHERE key='schema'").fetchone()
115
+ if row is None:
116
+ conn.execute("INSERT INTO meta VALUES ('schema', ?)", (str(DB_SCHEMA_VERSION),))
117
+ elif int(row[0]) > DB_SCHEMA_VERSION:
118
+ raise ValueError(f"{DB_NAME} schema {row[0]} is newer than this proxy2model "
119
+ f"(understands {DB_SCHEMA_VERSION}); upgrade the package")
120
+ elif int(row[0]) < DB_SCHEMA_VERSION:
121
+ # a graph.db from before build labels and entity history: add the column, keep every build
122
+ if "label" not in {r[1] for r in conn.execute("PRAGMA table_info(builds)")}:
123
+ conn.execute("ALTER TABLE builds ADD COLUMN label TEXT")
124
+ conn.execute("UPDATE meta SET value=? WHERE key='schema'", (str(DB_SCHEMA_VERSION),))
125
+
126
+
127
+ def is_damaged(e: BaseException) -> bool:
128
+ """True for SQLite's "not a database" and "disk image is malformed", not for a locked or busy file."""
129
+ return type(e) is sqlite3.DatabaseError
130
+
131
+
132
+ def open_db(path: str) -> sqlite3.Connection:
133
+ """Open an existing graph.db read-only-ish (creates nothing). A damaged file is refused, never rewritten."""
134
+ if not os.path.exists(path):
135
+ raise FileNotFoundError(path)
136
+ conn = connect(path)
137
+ try:
138
+ _init(conn)
139
+ except sqlite3.DatabaseError as e:
140
+ conn.close()
141
+ if is_damaged(e):
142
+ raise ValueError(f"{path} is damaged ({e}); the next build keeps it aside "
143
+ f"and starts a new one") from e
144
+ raise
145
+ return conn
146
+
147
+
148
+ def _set_aside(path: str) -> str:
149
+ """Move a damaged graph.db, with any journal SQLite left beside it, out of the way. Returns where it went."""
150
+ aside = keep_aside(path)
151
+ for ext in ("-journal", "-wal", "-shm"):
152
+ if os.path.exists(path + ext):
153
+ os.replace(path + ext, aside + ext) # a stale journal must never be replayed into the new file
154
+ return aside
155
+
156
+
157
+ def _req_path(line: str) -> str:
158
+ parts = (line or "").split(" ")
159
+ return parts[1] if len(parts) >= 2 else ""
160
+
161
+
162
+ def _headers_text(headers) -> str:
163
+ return "\n".join(f"{k}: {v}" for k, v in (headers or []))
164
+
165
+
166
+ # ------------------------------------------------------------------ write ----
167
+
168
+ def write_db(m: Model, path: str, osint: dict | None = None, label: str | None = None,
169
+ replace_build: int | None = None) -> dict:
170
+ """(Re)build graph.db from a Model, folding in OSINT. Returns a summary.
171
+
172
+ Each build is one transaction, so a crash leaves the previous build in place. A file SQLite
173
+ reports as damaged is kept aside as graph.db.damaged-<time> (named in the summary's
174
+ `damaged`) and a new graph.db is started; its build history is never silently deleted.
175
+
176
+ `replace_build` overwrites that build's snapshot instead of adding one: a live session
177
+ (`browse`) rebuilds every few seconds but is one build in the history.
178
+ """
179
+ try:
180
+ return _write_db(m, path, osint, label, replace_build)
181
+ except sqlite3.DatabaseError as e:
182
+ if not is_damaged(e) or not os.path.exists(path):
183
+ raise
184
+ aside = _set_aside(path)
185
+ out = _write_db(m, path, osint, label, replace_build)
186
+ out["damaged"] = aside
187
+ return out
188
+
189
+
190
+ def _write_db(m: Model, path: str, osint: dict | None, label: str | None,
191
+ replace_build: int | None = None) -> dict:
192
+ conn = connect(path)
193
+ try:
194
+ _init(conn)
195
+ with conn:
196
+ for t in _GRAPH_TABLES:
197
+ conn.execute(f"DELETE FROM {t}")
198
+ _write_graph(conn, m)
199
+ run_id = None
200
+ if osint:
201
+ run_id = _record_osint(conn, osint)
202
+ else:
203
+ row = conn.execute("SELECT max(id) FROM osint_runs").fetchone()
204
+ run_id = row[0]
205
+ if run_id is not None:
206
+ latest = conn.execute("SELECT data FROM osint_runs WHERE id=?",
207
+ (run_id,)).fetchone()
208
+ _write_osint_graph(conn, json.loads(latest[0]), m)
209
+ build_id = _snapshot(conn, m, run_id, label, replace_build)
210
+ _record_history(conn, m, build_id)
211
+ conn.execute("INSERT OR REPLACE INTO meta VALUES ('app', ?)", (m.name,))
212
+ conn.execute("INSERT OR REPLACE INTO meta VALUES ('built_at', ?)", (_now(),))
213
+ conn.execute("INSERT OR REPLACE INTO meta VALUES ('scope', ?)",
214
+ (json.dumps(m.scope),))
215
+ out = summary(conn)
216
+ out["build"] = build_id
217
+ return out
218
+ finally:
219
+ conn.close()
220
+
221
+
222
+ def _write_graph(conn: sqlite3.Connection, m: Model) -> None:
223
+ for n in m.nodes.values():
224
+ conn.execute("INSERT INTO nodes(id,type,layer,label,source,attrs,roles) "
225
+ "VALUES (?,?,?,?, 'traffic', ?, ?)",
226
+ (n.id, n.type, n.layer, n.label, json.dumps(n.attrs),
227
+ json.dumps(sorted(n.roles))))
228
+ conn.executemany("INSERT INTO node_evidence VALUES (?,?)",
229
+ [(n.id, i) for i in n.evidence])
230
+ for e in m.edges:
231
+ cur = conn.execute("INSERT INTO edges(src,dst,type,state) VALUES (?,?,?,?)",
232
+ (e.src, e.dst, e.type, e.state))
233
+ conn.executemany("INSERT INTO edge_evidence VALUES (?,?)",
234
+ [(cur.lastrowid, i) for i in e.evidence])
235
+ for ev in m.evidence_log:
236
+ rq, rs = ev.get("request") or {}, ev.get("response") or {}
237
+ conn.execute(
238
+ "INSERT INTO exchanges VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
239
+ (ev["id"], ev.get("source"), ev.get("item"), ev.get("role"),
240
+ ev.get("method"), ev.get("host"), _req_path(rq.get("line", "")),
241
+ ev.get("path"), ev.get("status"), ev.get("mime"),
242
+ rq.get("line"), _headers_text(rq.get("headers")), rq.get("body") or "",
243
+ 1 if rq.get("truncated") else 0,
244
+ rs.get("line"), _headers_text(rs.get("headers")), rs.get("body") or "",
245
+ 1 if rs.get("truncated") else 0))
246
+ for u in m.unknowns:
247
+ conn.execute("INSERT INTO unknowns(type,entity,we_know,we_dont_know,next_step) "
248
+ "VALUES (?,?,?,?,?)",
249
+ (u.type, u.entity, json.dumps(u.we_know), u.we_dont_know, u.next_step))
250
+ for s in m.secrets:
251
+ conn.execute("INSERT INTO secrets VALUES (?,?,?,?,?,?)",
252
+ (s.get("kind"), s.get("hmac_12"), s.get("length"), s.get("entropy"),
253
+ s.get("count", 1), json.dumps(s.get("evidence", []))))
254
+
255
+
256
+ def _snapshot(conn: sqlite3.Connection, m: Model, osint_run: int | None, label: str | None = None,
257
+ replace: int | None = None) -> int:
258
+ """Append this build's model to the history (or overwrite build `replace`); returns its build id."""
259
+ raw = io.BytesIO()
260
+ with gzip.GzipFile(fileobj=raw, mode="wb", compresslevel=6, mtime=0) as gz: # 9 is 8x slower for 4% less
261
+ for piece in dump_model(m): # piece by piece: the request log never exists as one string
262
+ gz.write(piece.encode("utf-8"))
263
+ blob = raw.getvalue()
264
+ if replace is not None and conn.execute("SELECT 1 FROM builds WHERE id=?", (replace,)).fetchone():
265
+ conn.execute("UPDATE builds SET built_at=?, nodes=?, edges=?, exchanges=?, osint_run=?, model_gz=?, "
266
+ "label=? WHERE id=?",
267
+ (_now(), len(m.nodes), len(m.edges), len(m.evidence_log), osint_run, blob,
268
+ (label or None), replace))
269
+ return replace
270
+ cur = conn.execute("INSERT INTO builds(built_at,nodes,edges,exchanges,osint_run,model_gz,label) "
271
+ "VALUES (?,?,?,?,?,?,?)",
272
+ (_now(), len(m.nodes), len(m.edges), len(m.evidence_log), osint_run, blob,
273
+ (label or None)))
274
+ keep = os.environ.get("PROXY2MODEL_KEEP_BUILDS", "")
275
+ if keep.isdigit() and int(keep) > 0:
276
+ conn.execute("DELETE FROM builds WHERE id <= (SELECT max(id) FROM builds) - ?", (int(keep),))
277
+ return cur.lastrowid
278
+
279
+
280
+ def edge_key(src: str, etype: str, dst: str) -> str:
281
+ return f"{src}|{etype}|{dst}"
282
+
283
+
284
+ def _record_history(conn: sqlite3.Connection, m: Model, build_id: int) -> None:
285
+ """First and last build and time each node and edge was seen. Older rows are only ever extended."""
286
+ now = _now()
287
+ rows = [("node", nid) for nid in m.nodes] + [("edge", edge_key(e.src, e.type, e.dst)) for e in m.edges]
288
+ conn.executemany(
289
+ "INSERT INTO entity_history(kind,key,first_build,last_build,first_seen,last_seen) "
290
+ "VALUES (?,?,?,?,?,?) ON CONFLICT(kind,key) DO UPDATE SET last_build=excluded.last_build, "
291
+ "last_seen=excluded.last_seen",
292
+ [(k, key, build_id, build_id, now, now) for k, key in rows])
293
+
294
+
295
+ def list_builds(conn: sqlite3.Connection) -> list[dict]:
296
+ return [{"id": r["id"], "built_at": r["built_at"], "nodes": r["nodes"], "edges": r["edges"],
297
+ "exchanges": r["exchanges"], "label": r["label"] or ""}
298
+ for r in conn.execute("SELECT id,built_at,nodes,edges,exchanges,label FROM builds ORDER BY id")]
299
+
300
+
301
+ def resolve_build(conn: sqlite3.Connection, ref: str | int | None) -> int:
302
+ """A build id from `latest`, `previous`, `first`, a number, or a label (newest match)."""
303
+ ids = [r[0] for r in conn.execute("SELECT id FROM builds ORDER BY id")]
304
+ if not ids:
305
+ raise ValueError("graph.db has no builds yet")
306
+ ref = "latest" if ref is None else ref
307
+ s = str(ref).strip().lower()
308
+ if s == "latest":
309
+ return ids[-1]
310
+ if s in ("previous", "prev"):
311
+ if len(ids) < 2:
312
+ raise ValueError("graph.db holds only one build so far; build again after the app changes")
313
+ return ids[-2]
314
+ if s == "first":
315
+ return ids[0]
316
+ if s.lstrip("#").isdigit():
317
+ n = int(s.lstrip("#"))
318
+ if n not in ids:
319
+ raise ValueError(f"no build #{n} in graph.db (have {ids[0]}..{ids[-1]})")
320
+ return n
321
+ row = conn.execute("SELECT id FROM builds WHERE lower(label)=? ORDER BY id DESC LIMIT 1", (s,)).fetchone()
322
+ if row:
323
+ return row[0]
324
+ raise ValueError(f"no build matches {ref!r}; use an id, latest, previous, first or a label")
325
+
326
+
327
+ def history(conn: sqlite3.Connection, kind: str, key: str) -> dict | None:
328
+ r = conn.execute("SELECT first_build,last_build,first_seen,last_seen FROM entity_history "
329
+ "WHERE kind=? AND key=?", (kind, key)).fetchone()
330
+ return dict(r) if r else None
331
+
332
+
333
+ # ------------------------------------------------------------------ OSINT ----
334
+
335
+ def _flatten(section: str, obj, prefix: str = ""):
336
+ """Yield (section, key, value) leaves of an osint section."""
337
+ if isinstance(obj, dict):
338
+ for k, v in obj.items():
339
+ yield from _flatten(section, v, f"{prefix}.{k}" if prefix else str(k))
340
+ elif isinstance(obj, list):
341
+ for x in obj:
342
+ yield from _flatten(section, x, prefix)
343
+ else:
344
+ yield section, prefix, "" if obj is None else str(obj)
345
+
346
+
347
+ def _record_osint(conn: sqlite3.Connection, osint: dict) -> int:
348
+ """Store one run (skipping an identical repeat) and return its id."""
349
+ data = json.dumps(osint, sort_keys=True)
350
+ last = conn.execute("SELECT id, data FROM osint_runs ORDER BY id DESC LIMIT 1").fetchone()
351
+ if last is not None and last[1] == data:
352
+ return last[0]
353
+ cur = conn.execute("INSERT INTO osint_runs(host,domain,generated_at,data) VALUES (?,?,?,?)",
354
+ (osint.get("host"), osint.get("domain"),
355
+ osint.get("generated_at") or _now(), data))
356
+ run_id = cur.lastrowid
357
+ rows = []
358
+ for section, val in osint.items():
359
+ if section.startswith("_") or section in ("host", "domain", "generated_at"):
360
+ continue
361
+ rows.extend((run_id, s, k, v) for s, k, v in _flatten(section, val))
362
+ conn.executemany("INSERT INTO osint_facts VALUES (?,?,?,?)", rows)
363
+ return run_id
364
+
365
+
366
+ def _ext(conn, nid: str, type_: str, label: str, anchor: str | None, edge: str,
367
+ **attrs) -> None:
368
+ if type_ == "subdomain" and conn.execute(
369
+ "SELECT 1 FROM nodes WHERE id=?", (f"host:{label}",)).fetchone():
370
+ nid = f"host:{label}" # already a first-party host: link it, don't duplicate
371
+ else:
372
+ _ext_node(conn, nid, type_, label, attrs)
373
+ if anchor and anchor != nid:
374
+ conn.execute("INSERT INTO edges(src,dst,type,state) VALUES (?,?,?, 'EXTERNAL')",
375
+ (anchor, nid, edge))
376
+
377
+
378
+ def _ext_node(conn, nid: str, type_: str, label: str, attrs: dict) -> None:
379
+ conn.execute("INSERT OR IGNORE INTO nodes(id,type,layer,label,source,attrs,roles) "
380
+ "VALUES (?,?,0,?, 'osint', ?, '[]')",
381
+ (nid, type_, label, json.dumps(attrs)))
382
+
383
+
384
+ def _write_osint_graph(conn: sqlite3.Connection, osint: dict, m: Model) -> None:
385
+ """Hang external recon off the app's host nodes as layer-0, source=osint nodes."""
386
+ host = (osint.get("host") or "").lower()
387
+ anchor = f"host:{host}"
388
+ if anchor not in m.nodes:
389
+ primary = [n.id for n in m.nodes.values() if n.type == "host"]
390
+ anchor = primary[0] if primary else None
391
+ if anchor is None:
392
+ conn.execute("INSERT OR IGNORE INTO nodes(id,type,layer,label,source,attrs,roles) "
393
+ "VALUES (?, 'host', 1, ?, 'osint', '{}', '[]')",
394
+ (f"host:{host}", host))
395
+ anchor = f"host:{host}"
396
+ domain = osint.get("domain") or host
397
+ known_hosts = {n.label for n in m.nodes.values() if n.type in ("host", "third_party")}
398
+
399
+ dns = osint.get("dns") or {}
400
+ for t in ("A", "AAAA"):
401
+ for ip in dns.get(t, []):
402
+ _ext(conn, f"ext:ip:{ip}", "ip", ip, anchor, "RESOLVES_TO", record=t)
403
+ for c in dns.get("CNAME", []):
404
+ _ext(conn, f"ext:cname:{c}", "dns_name", c, anchor, "ALIASES", record="CNAME")
405
+ for ns in dns.get("NS", []):
406
+ _ext(conn, f"ext:ns:{ns}", "nameserver", ns, anchor, "DELEGATED_TO")
407
+ for mx in dns.get("MX", []):
408
+ _ext(conn, f"ext:mx:{mx}", "mail_server", mx, anchor, "MAIL_VIA")
409
+ for caa in dns.get("CAA", []):
410
+ _ext(conn, f"ext:caa:{caa}", "dns_record", f"CAA {caa}", anchor, "HAS_RECORD",
411
+ record="CAA")
412
+
413
+ tls = osint.get("tls") or {}
414
+ if tls.get("issuer") and not tls.get("error"):
415
+ cid = f"ext:cert:{tls.get('subject_cn') or host}"
416
+ _ext(conn, cid, "certificate", f"cert · {tls['issuer']}", anchor, "PRESENTS_CERT",
417
+ issuer=tls.get("issuer"), subject=tls.get("subject_cn"),
418
+ not_after=tls.get("not_after"), days_until_expiry=tls.get("days_until_expiry"),
419
+ tls_version=tls.get("tls_version"))
420
+ for san in tls.get("san", []):
421
+ if san.startswith("*."): # a wildcard is a pattern, not a host you could visit
422
+ _ext(conn, f"ext:san:{san}", "wildcard_san", san, cid, "COVERS")
423
+ else:
424
+ _ext(conn, f"ext:sub:{san}", "subdomain", san, cid, "COVERS",
425
+ seen_in_capture=san in known_hosts, via="certificate")
426
+
427
+ subs = osint.get("subdomains") or {}
428
+ for name in subs.get("names", []):
429
+ _ext(conn, f"ext:sub:{name}", "subdomain", name, anchor, "SUBDOMAIN_OF",
430
+ seen_in_capture=name in known_hosts, via="certificate transparency")
431
+
432
+ for t in osint.get("technology", []):
433
+ name = t.get("name") if isinstance(t, dict) else str(t)
434
+ via = t.get("evidence") if isinstance(t, dict) else None
435
+ _ext(conn, f"ext:tech:{name}", "tech", name, anchor, "RUNS", seen_via=via)
436
+
437
+ geo = osint.get("ip_geo") or {}
438
+ if geo.get("network"):
439
+ _ext(conn, f"ext:net:{geo['network']}", "network", geo["network"], anchor,
440
+ "HOSTED_ON", ip=geo.get("ip"), country=geo.get("country"),
441
+ handle=geo.get("handle"))
442
+ reg = osint.get("registration") or {}
443
+ if reg.get("registrar") and not reg.get("error"):
444
+ _ext(conn, f"ext:registrar:{reg['registrar']}", "registration", reg["registrar"],
445
+ anchor, "REGISTERED_WITH", registered=reg.get("registration"),
446
+ expires=reg.get("expiration"), status=reg.get("status"))
447
+ em = osint.get("email_security") or {}
448
+ if em.get("spf") or em.get("dmarc") or em.get("dkim_selector_found"):
449
+ _ext(conn, f"ext:mail_policy:{domain}", "mail_policy", f"mail auth · {domain}",
450
+ anchor, "HAS_POLICY", spf=em.get("spf"), dmarc=em.get("dmarc_policy"),
451
+ spf_note=em.get("spf_note"), dmarc_note=em.get("dmarc_note"),
452
+ dkim=em.get("dkim_selector_found"))
453
+ http = osint.get("http") or {}
454
+ status = http.get("status")
455
+ # an error page's headers say nothing about the app behind it
456
+ if http and not http.get("error") and not (isinstance(status, int) and status >= 400):
457
+ _ext(conn, f"ext:headers:{host}", "security_headers",
458
+ f"security headers · {len(http.get('security_headers_missing', []))} missing",
459
+ anchor, "HAS_POLICY",
460
+ present=http.get("security_headers_present"),
461
+ missing=http.get("security_headers_missing"))
462
+ for p in (osint.get("ports") or {}).get("open", []):
463
+ _ext(conn, f"ext:port:{host}:{p['port']}", "port",
464
+ f"{p['port']}/{p.get('service', '?')}", anchor, "LISTENS_ON", **p)
465
+ robots = (osint.get("files") or {}).get("robots") or {}
466
+ for path in robots.get("disallow", []):
467
+ _ext(conn, f"ext:robots:{path}", "robots_path", path, anchor, "DISCLOSES",
468
+ file="robots.txt")
469
+
470
+
471
+ # ------------------------------------------------------------------- read ----
472
+
473
+ def summary(conn: sqlite3.Connection) -> dict:
474
+ one = lambda q: conn.execute(q).fetchone()[0]
475
+ return {
476
+ "nodes": one("SELECT count(*) FROM nodes"),
477
+ "edges": one("SELECT count(*) FROM edges"),
478
+ "exchanges": one("SELECT count(*) FROM exchanges"),
479
+ "external_nodes": one("SELECT count(*) FROM nodes WHERE source='osint'"),
480
+ "osint_runs": one("SELECT count(*) FROM osint_runs"),
481
+ "builds": one("SELECT count(*) FROM builds"),
482
+ }
483
+
484
+
485
+ def latest_osint(conn: sqlite3.Connection) -> dict | None:
486
+ row = conn.execute("SELECT data FROM osint_runs ORDER BY id DESC LIMIT 1").fetchone()
487
+ return json.loads(row[0]) if row else None
488
+
489
+
490
+ def load_build(conn: sqlite3.Connection, build_id: int | None = None) -> dict | None:
491
+ """A stored model.json-shaped snapshot (latest when build_id is None)."""
492
+ q = ("SELECT model_gz FROM builds WHERE id=?" if build_id is not None
493
+ else "SELECT model_gz FROM builds ORDER BY id DESC LIMIT 1")
494
+ row = conn.execute(q, (build_id,) if build_id is not None else ()).fetchone()
495
+ if not row:
496
+ return None
497
+ try:
498
+ return json.loads(gzip.decompress(row[0]))
499
+ except (OSError, EOFError, zlib.error, ValueError, TypeError) as e:
500
+ raise ValueError(f"the stored snapshot of build {'#' + str(build_id) if build_id else 'latest'} "
501
+ f"is damaged ({e})") from e
502
+
503
+
504
+ def table_names(conn: sqlite3.Connection) -> list[str]:
505
+ return [r[0] for r in conn.execute(
506
+ "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")]
507
+
508
+
509
+ _SAFE_SQL = re.compile(r"^\s*(select|with)\b", re.I)
510
+
511
+
512
+ def read_only_sql(conn: sqlite3.Connection, sql: str, params=()) -> list[sqlite3.Row]:
513
+ """Run a caller-supplied SELECT. Anything else is refused."""
514
+ if not _SAFE_SQL.match(sql) or ";" in sql.strip().rstrip(";"):
515
+ raise ValueError("only a single SELECT statement is allowed")
516
+ return conn.execute(sql, params).fetchall()
517
+
518
+
519
+ def export_for_report(conn: sqlite3.Connection) -> dict:
520
+ """The graph tables as plain JSON, for the report's in-page BQL console.
521
+
522
+ Requests are not included: the report already carries every redacted
523
+ request/response under `evidence`, and the console reads them from there.
524
+ """
525
+ ev_n: dict = {}
526
+ for r in conn.execute("SELECT node_id, ev_id FROM node_evidence ORDER BY ev_id"):
527
+ ev_n.setdefault(r[0], []).append(r[1])
528
+ ev_e: dict = {}
529
+ for r in conn.execute("SELECT edge_id, ev_id FROM edge_evidence ORDER BY ev_id"):
530
+ ev_e.setdefault(r[0], []).append(r[1])
531
+ nodes = [{"id": r["id"], "type": r["type"], "layer": r["layer"], "label": r["label"],
532
+ "source": r["source"], "roles": json.loads(r["roles"]),
533
+ "attrs": json.loads(r["attrs"]), "evidence": ev_n.get(r["id"], [])}
534
+ for r in conn.execute("SELECT * FROM nodes ORDER BY rowid")]
535
+ edges = [{"id": r["id"], "src": r["src"], "dst": r["dst"], "type": r["type"],
536
+ "state": r["state"], "evidence": ev_e.get(r["id"], [])}
537
+ for r in conn.execute("SELECT * FROM edges ORDER BY id")]
538
+ run = conn.execute("SELECT id, host, generated_at FROM osint_runs "
539
+ "ORDER BY id DESC LIMIT 1").fetchone()
540
+ facts = ([{"section": r[0], "key": r[1], "value": r[2]} for r in conn.execute(
541
+ "SELECT section, key, value FROM osint_facts WHERE run_id=? ORDER BY rowid",
542
+ (run["id"],))] if run else [])
543
+ unknowns = [{"type": r["type"], "entity": r["entity"],
544
+ "we_dont_know": r["we_dont_know"], "next_step": r["next_step"]}
545
+ for r in conn.execute("SELECT * FROM unknowns ORDER BY id")]
546
+ return {"nodes": nodes, "edges": edges, "facts": facts, "unknowns": unknowns,
547
+ "osint": dict(run) if run else None, "stats": summary(conn)}
@@ -0,0 +1 @@
1
+ """Exports to other formats."""