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
proxy2model/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""proxy2model — turn captured proxy traffic into an evidence-backed web-app model."""
|
|
2
|
+
__version__ = "2.0.0"
|
|
3
|
+
|
|
4
|
+
import os as _os
|
|
5
|
+
|
|
6
|
+
# the tool was called burp2model: its environment variables keep working
|
|
7
|
+
for _k, _v in list(_os.environ.items()):
|
|
8
|
+
if _k.startswith("BURP2MODEL_"):
|
|
9
|
+
_os.environ.setdefault("PROXY2MODEL_" + _k[len("BURP2MODEL_"):], _v)
|
|
10
|
+
|
|
11
|
+
from .ingest.parse import parse_items, Exchange # noqa: F401
|
|
12
|
+
from .core.model import build, to_dict, from_dict, cross_role, Model # noqa: F401
|
|
13
|
+
from .core.context import query, context_package # noqa: F401
|
|
14
|
+
from .core.graph import ReasonGraph, reason_graph, to_graphml, to_cypher, to_graph_json # noqa: F401
|
|
15
|
+
from .core.methodology import investigation_plan, methodology_package, rank_gaps # noqa: F401
|
|
16
|
+
from .core.changes import diff_models # noqa: F401
|
|
17
|
+
from .report.animate import render_terminal, render_svg # noqa: F401
|
|
18
|
+
from .core.domains import registrable_domain, public_suffix # noqa: F401
|
|
19
|
+
from .recon import osint # noqa: F401
|
proxy2model/__main__.py
ADDED
proxy2model/atomic.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Crash-safe file writes, and keeping a damaged file aside instead of losing it.
|
|
2
|
+
|
|
3
|
+
Every output is written to a temporary file next to its target and renamed over it once complete,
|
|
4
|
+
so a crash, a full disk or Ctrl-C mid-write leaves the previous file intact, never half of a new one.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import tempfile
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _umask() -> int:
|
|
16
|
+
mask = os.umask(0)
|
|
17
|
+
os.umask(mask)
|
|
18
|
+
return mask
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# the mode `open(path, "w")` gives a new file (read once: setting the umask to read it is not thread-safe)
|
|
22
|
+
_NEW_FILE_MODE = 0o666 & ~_umask()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@contextmanager
|
|
26
|
+
def atomic_write(path: str, mode: str = "w", encoding: str = "utf-8", perms: int | None = None):
|
|
27
|
+
"""Like `open(path, mode)`, but the file only appears, whole, when the block finishes without error.
|
|
28
|
+
|
|
29
|
+
The file keeps an existing target's permissions, else gets the usual umask ones, or `perms` when given.
|
|
30
|
+
"""
|
|
31
|
+
target = os.path.abspath(path)
|
|
32
|
+
if os.path.exists(target) and not os.path.isfile(target):
|
|
33
|
+
# /dev/stdout, a named pipe: nothing to rename over, write straight through
|
|
34
|
+
with open(target, mode, encoding=None if "b" in mode else encoding) as f:
|
|
35
|
+
yield f
|
|
36
|
+
return
|
|
37
|
+
fd, tmp = tempfile.mkstemp(prefix="." + os.path.basename(target) + ".", suffix=".tmp",
|
|
38
|
+
dir=os.path.dirname(target))
|
|
39
|
+
try:
|
|
40
|
+
with os.fdopen(fd, mode, encoding=None if "b" in mode else encoding) as f:
|
|
41
|
+
if perms is None:
|
|
42
|
+
try:
|
|
43
|
+
perms = os.stat(target).st_mode & 0o7777
|
|
44
|
+
except OSError:
|
|
45
|
+
perms = _NEW_FILE_MODE
|
|
46
|
+
os.chmod(tmp, perms)
|
|
47
|
+
yield f
|
|
48
|
+
f.flush()
|
|
49
|
+
os.fsync(f.fileno())
|
|
50
|
+
os.replace(tmp, target)
|
|
51
|
+
except BaseException:
|
|
52
|
+
try:
|
|
53
|
+
os.remove(tmp)
|
|
54
|
+
except OSError:
|
|
55
|
+
pass
|
|
56
|
+
raise
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def keep_aside(path: str) -> str:
|
|
60
|
+
"""Rename a damaged file to `<name>.damaged-<time>` so a fresh one can take its place. Returns the new path."""
|
|
61
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
62
|
+
dest = f"{path}.damaged-{stamp}"
|
|
63
|
+
n = 1
|
|
64
|
+
while os.path.exists(dest):
|
|
65
|
+
n += 1
|
|
66
|
+
dest = f"{path}.damaged-{stamp}-{n}"
|
|
67
|
+
os.replace(path, dest)
|
|
68
|
+
return dest
|
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
"""Commands that read a built model: query, q, graph, gaps, changes, openapi and the rest."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sqlite3
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from ..atomic import atomic_write
|
|
12
|
+
from ..ingest.parse import parse_items
|
|
13
|
+
from ..core.model import build, from_dict, cross_role
|
|
14
|
+
from ..core.context import SOURCE, query as run_query
|
|
15
|
+
from ..report.animate import render_terminal, write_svg
|
|
16
|
+
from ..core.graph import (
|
|
17
|
+
ReasonGraph, reason_graph, to_graphml, to_cypher, to_graph_json,
|
|
18
|
+
)
|
|
19
|
+
from ..core.methodology import (
|
|
20
|
+
investigation_plan, methodology_package, rank_gaps,
|
|
21
|
+
METHODOLOGY_PROMPT, FALSIFY_PROMPT,
|
|
22
|
+
)
|
|
23
|
+
from ..core.changes import diff_models
|
|
24
|
+
from ..export.openapi import to_openapi
|
|
25
|
+
from ..db import store, bql
|
|
26
|
+
from . import ui
|
|
27
|
+
from .common import (
|
|
28
|
+
_init_key, _db_path, _model_path, _reload,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cmd_q(args) -> int:
|
|
33
|
+
path = _db_path(args)
|
|
34
|
+
if path is None:
|
|
35
|
+
return 2
|
|
36
|
+
try:
|
|
37
|
+
conn = bql.connect(path)
|
|
38
|
+
except (ValueError, sqlite3.Error) as e:
|
|
39
|
+
print(f"could not open {path}: {e}", file=sys.stderr)
|
|
40
|
+
return 2
|
|
41
|
+
|
|
42
|
+
def run_one(text: str) -> int:
|
|
43
|
+
try:
|
|
44
|
+
res = bql.run_query(conn, text, limit=args.limit)
|
|
45
|
+
except (bql.BQLError, sqlite3.Error) as e:
|
|
46
|
+
print(f"error: {e}", file=sys.stderr)
|
|
47
|
+
return 2
|
|
48
|
+
print(bql.render_json(res) if args.format == "json" else bql.render_text(res))
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
if args.query:
|
|
53
|
+
return run_one(" ".join(args.query))
|
|
54
|
+
print(f"proxy2model q · {args.webapp} · graph.db — `help` for the language, Ctrl-D to exit")
|
|
55
|
+
while True:
|
|
56
|
+
try:
|
|
57
|
+
line = input("bql> ").strip()
|
|
58
|
+
except (EOFError, KeyboardInterrupt):
|
|
59
|
+
print()
|
|
60
|
+
return 0
|
|
61
|
+
if line in ("exit", "quit"):
|
|
62
|
+
return 0
|
|
63
|
+
if line:
|
|
64
|
+
run_one(line)
|
|
65
|
+
finally:
|
|
66
|
+
conn.close()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_query(args) -> int:
|
|
70
|
+
path = _model_path(args)
|
|
71
|
+
if path is None:
|
|
72
|
+
return 2
|
|
73
|
+
m = _reload(path)
|
|
74
|
+
if m is None:
|
|
75
|
+
return 2
|
|
76
|
+
print(run_query(m, args.question))
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_crossrole(args) -> int:
|
|
81
|
+
path = _model_path(args)
|
|
82
|
+
if path is None:
|
|
83
|
+
return 2
|
|
84
|
+
m = _reload(path)
|
|
85
|
+
if m is None:
|
|
86
|
+
return 2
|
|
87
|
+
missing = [r for r in (args.low, args.high) if r not in m.roles]
|
|
88
|
+
if missing:
|
|
89
|
+
print(f"role(s) not in this model: {', '.join(missing)} "
|
|
90
|
+
f"(model has: {', '.join(sorted(m.roles)) or 'none'}).\n"
|
|
91
|
+
f"Build each capture with --role <name> into the same --out/--webapp.",
|
|
92
|
+
file=sys.stderr)
|
|
93
|
+
return 2
|
|
94
|
+
result = cross_role(m, args.low, args.high)
|
|
95
|
+
if args.json:
|
|
96
|
+
print(json.dumps(result, indent=2))
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
def show(title, items):
|
|
100
|
+
print(f"{title} ({len(items)})")
|
|
101
|
+
for s in items:
|
|
102
|
+
ev = ", ".join(f"ev_{e}" for e in s["evidence"])
|
|
103
|
+
st = " ".join(f"{r}={v}" for r, v in s["statuses"].items())
|
|
104
|
+
print(f" {s['endpoint']:<44} {ev} {st}")
|
|
105
|
+
print(f" unknown: {s['unknown']} · next: {s['next']}")
|
|
106
|
+
print()
|
|
107
|
+
|
|
108
|
+
show(f"'{args.low}' reached privileged-looking endpoints", result["low_reached_privileged"])
|
|
109
|
+
show(f"'{args.high}' reached, '{args.low}' never tried", result["high_only"])
|
|
110
|
+
show("both roles got 2xx", result["shared_same_success"])
|
|
111
|
+
print("Each is a hypothesis, not a finding.")
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def cmd_osint(args) -> int:
|
|
116
|
+
from ..recon import osint as osint_mod
|
|
117
|
+
|
|
118
|
+
host = args.host.strip().lower().replace("https://", "").replace("http://", "")
|
|
119
|
+
host = host.split("/")[0].split(":")[0]
|
|
120
|
+
if not re.match(r"^[a-z0-9.\-]{1,253}$", host) or "." not in host:
|
|
121
|
+
print(f"not a hostname: {args.host!r}", file=sys.stderr)
|
|
122
|
+
return 2
|
|
123
|
+
if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", host):
|
|
124
|
+
print(f"{host} is an IP address — osint needs a hostname "
|
|
125
|
+
"(DNS, certificate transparency and RDAP are domain lookups)", file=sys.stderr)
|
|
126
|
+
return 2
|
|
127
|
+
# Active recon reaches out to the target and to public services. Gate it.
|
|
128
|
+
if not (args.yes or os.environ.get("PROXY2MODEL_OSINT_CONSENT") == "1"):
|
|
129
|
+
print(
|
|
130
|
+
"osint sends live requests to the target and to public services "
|
|
131
|
+
"(DNS, TLS, HTTP" + (", TCP ports" if args.ports else "") + ", CT logs, RDAP).\n"
|
|
132
|
+
f"Only run this against hosts you are authorized to test.\n"
|
|
133
|
+
f" target: {host}\n"
|
|
134
|
+
"Re-run with --yes to confirm you have authorization.",
|
|
135
|
+
file=sys.stderr)
|
|
136
|
+
return 2
|
|
137
|
+
|
|
138
|
+
result = osint_mod.run(host, timeout=args.timeout, want_ports=args.ports,
|
|
139
|
+
want_subdomains=not args.no_subdomains)
|
|
140
|
+
blob = json.dumps(result.to_dict(), indent=2)
|
|
141
|
+
saved = None
|
|
142
|
+
try:
|
|
143
|
+
if args.webapp:
|
|
144
|
+
outdir = os.path.join(args.out, args.webapp)
|
|
145
|
+
os.makedirs(outdir, exist_ok=True)
|
|
146
|
+
saved = os.path.join(outdir, "osint.json")
|
|
147
|
+
with atomic_write(saved) as f:
|
|
148
|
+
f.write(blob)
|
|
149
|
+
if args.out_file:
|
|
150
|
+
with atomic_write(args.out_file) as f:
|
|
151
|
+
f.write(blob)
|
|
152
|
+
saved = args.out_file
|
|
153
|
+
except OSError as e:
|
|
154
|
+
print(f"could not write osint report: {e}", file=sys.stderr)
|
|
155
|
+
return 2
|
|
156
|
+
if args.json:
|
|
157
|
+
print(blob)
|
|
158
|
+
else:
|
|
159
|
+
print(osint_mod.render_text(result))
|
|
160
|
+
if saved:
|
|
161
|
+
print(f"\nwrote {saved}", file=sys.stderr)
|
|
162
|
+
if args.webapp:
|
|
163
|
+
mp = os.path.join(args.out, args.webapp, "model.json")
|
|
164
|
+
if os.path.exists(mp):
|
|
165
|
+
try:
|
|
166
|
+
with open(mp, encoding="utf-8") as f:
|
|
167
|
+
model = from_dict(json.load(f))
|
|
168
|
+
dbs = store.write_db(model, os.path.join(args.out, args.webapp,
|
|
169
|
+
store.DB_NAME),
|
|
170
|
+
osint=result.to_dict())
|
|
171
|
+
if dbs.get("damaged"):
|
|
172
|
+
print(f"warning: graph.db was damaged; kept it aside as {dbs['damaged']}",
|
|
173
|
+
file=sys.stderr)
|
|
174
|
+
print(f"graph.db updated ({dbs['external_nodes']} external nodes, "
|
|
175
|
+
f"{dbs['osint_runs']} recon run(s) stored)", file=sys.stderr)
|
|
176
|
+
except (OSError, ValueError, KeyError, TypeError, sqlite3.Error) as e:
|
|
177
|
+
print(f"could not update graph.db: {e}", file=sys.stderr)
|
|
178
|
+
print("run the build again for this webapp to fold it into "
|
|
179
|
+
"report.html and context.json.", file=sys.stderr)
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _render_reason(rg, m) -> str:
|
|
184
|
+
"""A compact text overview of the graph's reasoning views."""
|
|
185
|
+
L = [f"{m.name} — graph reasoning ({len(m.nodes)} nodes, {len(m.edges)} edges)", ""]
|
|
186
|
+
comms = rg.communities()
|
|
187
|
+
L.append(f"Communities ({len(comms)}) — functional areas")
|
|
188
|
+
for c in comms[:8]:
|
|
189
|
+
kinds = ", ".join(f"{v} {k}" for k, v in c["kinds"].items())
|
|
190
|
+
L.append(f" {c['id']} '{c['name']}' {c['size']} nodes ({kinds})")
|
|
191
|
+
L.append("")
|
|
192
|
+
L.append("Hubs — the load-bearing nodes")
|
|
193
|
+
for h in rg.hubs(8):
|
|
194
|
+
L.append(f" {h['degree']:>3} {h['type']:<12} {h['label']}")
|
|
195
|
+
L.append("")
|
|
196
|
+
zones = rg.trust_zones()
|
|
197
|
+
L.append("Trust zones — how identity reaches the surface")
|
|
198
|
+
for z, rows in zones.items():
|
|
199
|
+
L.append(f" {z:<18} {len(rows)} endpoint(s)")
|
|
200
|
+
L.append("")
|
|
201
|
+
coup = rg.coupling()
|
|
202
|
+
L.append(f"Coupling — {len(coup['cross_community'])} cross-area edge(s), "
|
|
203
|
+
f"{len(coup['to_third_party'])} to third parties")
|
|
204
|
+
L.append("")
|
|
205
|
+
L.append("Source: model graph · Model call: none")
|
|
206
|
+
return "\n".join(L)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def cmd_graph(args) -> int:
|
|
210
|
+
path = _model_path(args)
|
|
211
|
+
if path is None:
|
|
212
|
+
return 2
|
|
213
|
+
m = _reload(path)
|
|
214
|
+
if m is None:
|
|
215
|
+
return 2
|
|
216
|
+
rg = ReasonGraph(m)
|
|
217
|
+
|
|
218
|
+
if args.reach or args.blast or args.touched_by:
|
|
219
|
+
target = (args.reach or args.blast or args.touched_by).lower()
|
|
220
|
+
# rank: prefer a label hit over an id hit, a meaningful node over a
|
|
221
|
+
# parameter/operation/cookie, and the shortest label among equals
|
|
222
|
+
leaf = ("parameter", "operation", "cookie")
|
|
223
|
+
scored = []
|
|
224
|
+
for nid, n in m.nodes.items():
|
|
225
|
+
lbl = n.label.lower()
|
|
226
|
+
if target in lbl:
|
|
227
|
+
rank = 0 if lbl == target else 1
|
|
228
|
+
elif target in nid.lower():
|
|
229
|
+
rank = 2
|
|
230
|
+
else:
|
|
231
|
+
continue
|
|
232
|
+
scored.append(((rank, n.type in leaf, len(n.label)), nid))
|
|
233
|
+
if not scored:
|
|
234
|
+
print(f"no node matches {target!r}", file=sys.stderr)
|
|
235
|
+
return 2
|
|
236
|
+
scored.sort()
|
|
237
|
+
nid = scored[0][1]
|
|
238
|
+
if args.reach:
|
|
239
|
+
result = rg.reach(nid)
|
|
240
|
+
elif args.blast:
|
|
241
|
+
result = rg.blast(nid, depth=args.depth)
|
|
242
|
+
else:
|
|
243
|
+
result = rg.touched_by(nid, depth=args.depth)
|
|
244
|
+
print(json.dumps(result, indent=2))
|
|
245
|
+
return 0
|
|
246
|
+
|
|
247
|
+
fmt = args.format
|
|
248
|
+
if fmt == "graphml":
|
|
249
|
+
out = to_graphml(m)
|
|
250
|
+
elif fmt == "cypher":
|
|
251
|
+
out = to_cypher(m)
|
|
252
|
+
elif fmt == "json":
|
|
253
|
+
out = json.dumps(to_graph_json(m), indent=2)
|
|
254
|
+
elif fmt == "reason":
|
|
255
|
+
out = json.dumps(reason_graph(m), indent=2)
|
|
256
|
+
else:
|
|
257
|
+
out = _render_reason(rg, m)
|
|
258
|
+
|
|
259
|
+
if args.out_file:
|
|
260
|
+
try:
|
|
261
|
+
with atomic_write(args.out_file) as f:
|
|
262
|
+
f.write(out)
|
|
263
|
+
except OSError as e:
|
|
264
|
+
print(f"could not write {args.out_file}: {e}", file=sys.stderr)
|
|
265
|
+
return 2
|
|
266
|
+
print(f"wrote {args.out_file}", file=sys.stderr)
|
|
267
|
+
else:
|
|
268
|
+
print(out)
|
|
269
|
+
return 0
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _render_plan(plan: dict) -> str:
|
|
273
|
+
p = plan["posture"]
|
|
274
|
+
L = [f"{plan['app']} — investigation scaffold (scope: {', '.join(plan['scope']) or '?'})", ""]
|
|
275
|
+
L.append(f"{p['endpoints']} endpoints · {p['feature_areas']} feature areas · "
|
|
276
|
+
f"roles: {', '.join(p['roles']) or 'none'}")
|
|
277
|
+
L.append(f"{p['static_only']} code-only · {p['privileged']} privileged · "
|
|
278
|
+
f"{p['credentialed']} credentialed · {p['open_questions']} open questions")
|
|
279
|
+
L.append("")
|
|
280
|
+
L.append("RECON — establish first")
|
|
281
|
+
for r in plan["recon"]:
|
|
282
|
+
L.append(f" • {r['step']}")
|
|
283
|
+
L.append(f" why: {r['why']}")
|
|
284
|
+
L.append(f" how: {r['how']}")
|
|
285
|
+
L.append("")
|
|
286
|
+
L.append("LINES OF INQUIRY — by leverage")
|
|
287
|
+
for ln in plan["lines_of_inquiry"]:
|
|
288
|
+
if not ln["endpoints"]:
|
|
289
|
+
continue
|
|
290
|
+
L.append(f" [{ln['leverage']:>2}] {ln['area']} — {ln['why_it_matters']}")
|
|
291
|
+
if ln["reach"]:
|
|
292
|
+
L.append(f" reached {'; '.join(ln['reach'])}")
|
|
293
|
+
for s in ln["signals"][:5]:
|
|
294
|
+
ev = ", ".join(f"ev_{i}" for i in s["evidence"])
|
|
295
|
+
L.append(f" - {s['endpoint']}: {s['observation']} [{ev}]")
|
|
296
|
+
L.append(f" → {s['question']}")
|
|
297
|
+
L.append(f" next: {s['next']}")
|
|
298
|
+
for q in ln["open_questions"][:4]:
|
|
299
|
+
ent = f"{q['entity']}: " if q.get("entity") else ""
|
|
300
|
+
L.append(f" ? {ent}{q['unknown']} — {q['to_find_out']}")
|
|
301
|
+
L.append("")
|
|
302
|
+
if plan["watch"]["third_party_trust"]:
|
|
303
|
+
L.append("WATCH — trust leaving the app")
|
|
304
|
+
for e in plan["watch"]["third_party_trust"][:8]:
|
|
305
|
+
L.append(f" {e['from']} → {e['to']} ({e['edge'].lower()})")
|
|
306
|
+
L.append("")
|
|
307
|
+
L.append("This is a scaffold, not the methodology. Hand it to an AI with "
|
|
308
|
+
"`--prompt` to reason a sequenced, target-specific plan.")
|
|
309
|
+
L.append("Source: model graph · Model call: none")
|
|
310
|
+
return "\n".join(L)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def cmd_methodology(args) -> int:
|
|
314
|
+
path = _model_path(args)
|
|
315
|
+
if path is None:
|
|
316
|
+
return 2
|
|
317
|
+
m = _reload(path)
|
|
318
|
+
if m is None:
|
|
319
|
+
return 2
|
|
320
|
+
if args.json:
|
|
321
|
+
out = json.dumps(investigation_plan(m), indent=2)
|
|
322
|
+
elif args.prompt:
|
|
323
|
+
pkg = methodology_package(m)
|
|
324
|
+
out = ("\n".join(METHODOLOGY_PROMPT) + "\n\n"
|
|
325
|
+
+ "MODEL AND SCAFFOLD (JSON):\n```json\n"
|
|
326
|
+
+ json.dumps({"plan_scaffold": pkg["plan_scaffold"],
|
|
327
|
+
"model": pkg["model"]}, separators=(",", ":")) + "\n```")
|
|
328
|
+
else:
|
|
329
|
+
out = _render_plan(investigation_plan(m))
|
|
330
|
+
if args.out_file:
|
|
331
|
+
try:
|
|
332
|
+
with atomic_write(args.out_file) as f:
|
|
333
|
+
f.write(out)
|
|
334
|
+
except OSError as e:
|
|
335
|
+
print(f"could not write {args.out_file}: {e}", file=sys.stderr)
|
|
336
|
+
return 2
|
|
337
|
+
print(f"wrote {args.out_file}", file=sys.stderr)
|
|
338
|
+
else:
|
|
339
|
+
print(out)
|
|
340
|
+
return 0
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def cmd_openapi(args) -> int:
|
|
344
|
+
path = _model_path(args)
|
|
345
|
+
if path is None:
|
|
346
|
+
return 2
|
|
347
|
+
m = _reload(path)
|
|
348
|
+
if m is None:
|
|
349
|
+
return 2
|
|
350
|
+
spec = to_openapi(m, host=args.host, include_static=args.include_static)
|
|
351
|
+
text = json.dumps(spec, indent=2)
|
|
352
|
+
if args.output in (None, "-"):
|
|
353
|
+
print(text)
|
|
354
|
+
return 0
|
|
355
|
+
try:
|
|
356
|
+
with atomic_write(args.output) as f:
|
|
357
|
+
f.write(text + "\n")
|
|
358
|
+
except OSError as e:
|
|
359
|
+
print(ui.error(f"could not write {args.output}: {e}"), file=sys.stderr)
|
|
360
|
+
return 2
|
|
361
|
+
print(ui.ok(f"wrote {args.output} ({len(spec['paths'])} paths, "
|
|
362
|
+
f"{sum(len(v) for v in spec['paths'].values())} operations)"), file=sys.stderr)
|
|
363
|
+
return 0
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def cmd_gaps(args) -> int:
|
|
367
|
+
path = _model_path(args)
|
|
368
|
+
if path is None:
|
|
369
|
+
return 2
|
|
370
|
+
m = _reload(path)
|
|
371
|
+
if m is None:
|
|
372
|
+
return 2
|
|
373
|
+
g = rank_gaps(m)
|
|
374
|
+
if args.json:
|
|
375
|
+
print(json.dumps(g, indent=2))
|
|
376
|
+
return 0
|
|
377
|
+
print(f"{g['app']} — gaps, ranked by how much resolving each improves the picture")
|
|
378
|
+
print()
|
|
379
|
+
for i, gap in enumerate(g["gaps"], 1):
|
|
380
|
+
print(f"#{i} [{gap['leverage']:>2}] {gap['entity']}")
|
|
381
|
+
print(f" unknown: {gap['unknown']}")
|
|
382
|
+
print(f" next: {gap['next']}")
|
|
383
|
+
print()
|
|
384
|
+
print("absence = a gap in observation, never a statement about the target")
|
|
385
|
+
print(SOURCE)
|
|
386
|
+
return 0
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def cmd_changes(args) -> int:
|
|
390
|
+
new = _reload(_model_path(args)) if _model_path(args) else None
|
|
391
|
+
if new is None:
|
|
392
|
+
return 2
|
|
393
|
+
if bool(args.against) == bool(args.against_build):
|
|
394
|
+
print("give exactly one of --against OLD_MODEL_JSON or --against-build "
|
|
395
|
+
"(an id from graph.db's build history, or `previous`)", file=sys.stderr)
|
|
396
|
+
return 2
|
|
397
|
+
try:
|
|
398
|
+
if args.against:
|
|
399
|
+
with open(args.against, encoding="utf-8") as f:
|
|
400
|
+
old = from_dict(json.load(f))
|
|
401
|
+
else:
|
|
402
|
+
dbp = _db_path(args)
|
|
403
|
+
if dbp is None:
|
|
404
|
+
return 2
|
|
405
|
+
conn = store.open_db(dbp)
|
|
406
|
+
try:
|
|
407
|
+
bid = None if args.against_build == "previous" else int(args.against_build)
|
|
408
|
+
if bid is None:
|
|
409
|
+
rows = conn.execute("SELECT id FROM builds ORDER BY id DESC LIMIT 2").fetchall()
|
|
410
|
+
if len(rows) < 2:
|
|
411
|
+
print("graph.db holds only one build so far — build again after "
|
|
412
|
+
"the app changes", file=sys.stderr)
|
|
413
|
+
return 2
|
|
414
|
+
bid = rows[1][0]
|
|
415
|
+
snap = store.load_build(conn, bid)
|
|
416
|
+
finally:
|
|
417
|
+
conn.close()
|
|
418
|
+
if snap is None:
|
|
419
|
+
print(f"no build #{bid} in graph.db", file=sys.stderr)
|
|
420
|
+
return 2
|
|
421
|
+
old = from_dict(snap)
|
|
422
|
+
except (OSError, ValueError, KeyError, TypeError, sqlite3.Error) as e:
|
|
423
|
+
what = f"--against {args.against}" if args.against else "the stored build"
|
|
424
|
+
print(f"could not load {what}: {e}", file=sys.stderr)
|
|
425
|
+
return 2
|
|
426
|
+
d = diff_models(old, new)
|
|
427
|
+
if args.json:
|
|
428
|
+
print(json.dumps(d, indent=2))
|
|
429
|
+
return 0
|
|
430
|
+
print(f"{d['app']} — what changed (+{d['totals']['appeared']} appeared, "
|
|
431
|
+
f"-{d['totals']['gone']} gone)")
|
|
432
|
+
print()
|
|
433
|
+
if not d["areas"]:
|
|
434
|
+
print("No drift between these two models.")
|
|
435
|
+
return 0
|
|
436
|
+
for a in d["areas"]:
|
|
437
|
+
print(f"● {a['area']}")
|
|
438
|
+
for x in a["appeared"]:
|
|
439
|
+
ev = ", ".join(f"ev_{i}" for i in x.get("evidence", []))
|
|
440
|
+
print(f" + {x['kind']:<11} {x['label']}")
|
|
441
|
+
print(f" {x['why']}" + (f" [{ev}]" if ev else ""))
|
|
442
|
+
for x in a["gone"]:
|
|
443
|
+
print(f" - {x['kind']:<11} {x['label']}")
|
|
444
|
+
print(f" {x['why']}")
|
|
445
|
+
print()
|
|
446
|
+
print(d["note"])
|
|
447
|
+
print(SOURCE)
|
|
448
|
+
return 0
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def cmd_falsify(args) -> int:
|
|
452
|
+
path = _model_path(args)
|
|
453
|
+
if path is None:
|
|
454
|
+
return 2
|
|
455
|
+
m = _reload(path)
|
|
456
|
+
if m is None:
|
|
457
|
+
return 2
|
|
458
|
+
pkg = methodology_package(m, prompt=FALSIFY_PROMPT)
|
|
459
|
+
out = ("\n".join(FALSIFY_PROMPT) + "\n\n"
|
|
460
|
+
+ "MODEL AND HYPOTHESES (JSON):\n```json\n"
|
|
461
|
+
+ json.dumps({"plan_scaffold": pkg["plan_scaffold"],
|
|
462
|
+
"model": pkg["model"]}, indent=2) + "\n```")
|
|
463
|
+
if args.out_file:
|
|
464
|
+
try:
|
|
465
|
+
with atomic_write(args.out_file) as f:
|
|
466
|
+
f.write(out)
|
|
467
|
+
except OSError as e:
|
|
468
|
+
print(f"could not write {args.out_file}: {e}", file=sys.stderr)
|
|
469
|
+
return 2
|
|
470
|
+
print(f"wrote {args.out_file}", file=sys.stderr)
|
|
471
|
+
else:
|
|
472
|
+
print(out)
|
|
473
|
+
return 0
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def cmd_animate(args) -> int:
|
|
477
|
+
"""Standalone animation from an existing model or a fresh build."""
|
|
478
|
+
if args.log:
|
|
479
|
+
_init_key()
|
|
480
|
+
try:
|
|
481
|
+
m = build(list(parse_items(args.log)), name=args.webapp)
|
|
482
|
+
except (OSError, ValueError, SyntaxError) as e:
|
|
483
|
+
print(f"could not read {args.log}: {e}", file=sys.stderr)
|
|
484
|
+
return 2
|
|
485
|
+
else:
|
|
486
|
+
path = _model_path(args)
|
|
487
|
+
if path is None:
|
|
488
|
+
return 2
|
|
489
|
+
m = _reload(path)
|
|
490
|
+
if m is None:
|
|
491
|
+
return 2
|
|
492
|
+
if args.svg:
|
|
493
|
+
try:
|
|
494
|
+
write_svg(m, args.svg)
|
|
495
|
+
except OSError as e:
|
|
496
|
+
print(f"could not write {args.svg}: {e}", file=sys.stderr)
|
|
497
|
+
return 2
|
|
498
|
+
print(f"wrote {args.svg}")
|
|
499
|
+
else:
|
|
500
|
+
render_terminal(m, fast=args.no_type)
|
|
501
|
+
return 0
|