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,279 @@
1
+ """`browse`: open a browser, record what you do in it, and keep the model up to date."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import json
7
+ import os
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ import threading
14
+ import time
15
+ import urllib.parse
16
+ import webbrowser
17
+
18
+ from ..core.domains import registrable_domain
19
+ from ..ingest import live as live_mod
20
+ from ..ingest.browse import Recorder
21
+ from ..ingest.browser import CDP, WebSocket, find_chrome, launch_chrome
22
+ from ..ingest.crawl import CrawlConfig, in_scope
23
+ from . import ui
24
+ from .build import _write_outputs
25
+ from .common import _clear_inputs, _init_key, _inputs_dir
26
+
27
+
28
+ def _ask(prompt: str, default: str = "") -> str:
29
+ shown = f" {prompt}" + (f" {ui.style('[' + default + ']', 'dim')}" if default else "") + ": "
30
+ try:
31
+ return input(shown).strip() or default
32
+ except EOFError:
33
+ return default
34
+
35
+
36
+ def _start_url(text: str) -> str:
37
+ text = text.strip()
38
+ return text if "://" in text else "https://" + text
39
+
40
+
41
+ _ROLE_RE = re.compile(r"[A-Za-z0-9._-]{1,40}")
42
+
43
+
44
+ def _bare(host: str) -> bool:
45
+ """An IP address or a one-label name (localhost): no domain to widen the scope to."""
46
+ try:
47
+ ipaddress.ip_address(host.strip("[]"))
48
+ return True
49
+ except ValueError:
50
+ return "." not in host
51
+
52
+
53
+ def _settings(args) -> bool:
54
+ """Fill in the target, name, scope and mode, asking for what the command line left out.
55
+ False when there is not enough to go on."""
56
+ interactive = sys.stdin.isatty() and not args.yes
57
+ if not args.url:
58
+ if not interactive:
59
+ print(ui.error("give the app's URL: proxy2model browse https://app.example.com"), file=sys.stderr)
60
+ return False
61
+ print()
62
+ args.url = _ask("Web app URL", "")
63
+ if not args.url:
64
+ return False
65
+ args.url = _start_url(args.url)
66
+ host = (urllib.parse.urlsplit(args.url).hostname or "").lower()
67
+ if not host:
68
+ print(ui.error(f"not a URL: {args.url}"), file=sys.stderr)
69
+ return False
70
+ if not args.scope:
71
+ default = host if _bare(host) else f"*.{registrable_domain(host) or host}"
72
+ args.scope = [_ask("Scope, comma-separated (*.example.com is the domain and its subdomains)", default)
73
+ if interactive else default]
74
+ if not args.webapp:
75
+ default = "app" if _bare(host) else (registrable_domain(host) or host).split(".")[0]
76
+ args.webapp = _ask("Name for the model", default) if interactive else default
77
+ if args.active is None:
78
+ args.active = (_ask("Mode: passive (only what you browse) or active (also crawl in a background tab)",
79
+ "passive").lower().startswith("a")) if interactive else False
80
+ if args.active and not args.yes:
81
+ if not interactive or not _ask("Active mode sends requests of its own (GET only, unless --allow-writes). "
82
+ "Is this an app you are authorized to test? (yes/no)", "no").lower().startswith("y"):
83
+ print(ui.error("active mode needs authorization: answer yes, or pass --yes"), file=sys.stderr)
84
+ return False
85
+ if args.role is None and interactive:
86
+ args.role = _ask("Role for this session, e.g. anon, user, admin (Enter for none)", "") or None
87
+ if args.role and not _ROLE_RE.fullmatch(args.role):
88
+ print(ui.error("a role is letters, digits, '.', '_' or '-'"), file=sys.stderr)
89
+ return False
90
+ if args.keep_login is None:
91
+ args.keep_login = interactive and _ask(
92
+ "Keep this browser's login for the next session? It stays on disk outside the output (yes/no)",
93
+ "no").lower().startswith("y")
94
+ return True
95
+
96
+
97
+ def _profile_dir(webapp: str, role: str | None) -> str:
98
+ """Where a kept login lives: real cookies, so outside any output folder, one per app and role."""
99
+ base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
100
+ return os.path.join(base, "proxy2model", "profiles", webapp + (f"-{role}" if role else ""))
101
+
102
+
103
+ def _scope_list(values: list[str]) -> list[str]:
104
+ out = []
105
+ for v in values:
106
+ for x in v.split(","):
107
+ x = x.strip().lower()
108
+ if x:
109
+ x = x.split("://", 1)[-1].split("/", 1)[0]
110
+ out.append(x)
111
+ return out
112
+
113
+
114
+ def _open(ws_url: str, url: str) -> None:
115
+ """Load the start page in the browser's first tab."""
116
+ c = CDP(WebSocket(ws_url))
117
+ try:
118
+ pages = [t for t in c.call("Target.getTargets")["targetInfos"] if t["type"] == "page"]
119
+ if pages:
120
+ sid = c.call("Target.attachToTarget", {"targetId": pages[0]["targetId"], "flatten": True})["sessionId"]
121
+ c.call("Page.navigate", {"url": url}, sid, 30)
122
+ else:
123
+ c.call("Target.createTarget", {"url": url})
124
+ finally:
125
+ c.ws.close()
126
+
127
+
128
+ def cmd_browse(args) -> int:
129
+ if not _settings(args):
130
+ return 2
131
+ chrome = find_chrome(args.chrome)
132
+ if not chrome:
133
+ print(ui.error("no Chrome, Chromium, Edge or Brave found; install one or pass --chrome PATH"), file=sys.stderr)
134
+ return 2
135
+ scope = _scope_list(args.scope)
136
+ args.scope = [s[2:] if s.startswith("*.") else s for s in scope] # first-party, for the model
137
+ start_host = (urllib.parse.urlsplit(args.url).hostname or "").lower()
138
+ args.seed_host = start_host # the site you opened is the app's main host, not the busiest one
139
+ if not in_scope(args.url, scope):
140
+ scope.append(start_host)
141
+
142
+ _init_key()
143
+ outdir = os.path.join(args.out, args.webapp)
144
+ os.makedirs(outdir, exist_ok=True)
145
+ if args.fresh:
146
+ _clear_inputs(outdir)
147
+ key = f"browse-{args.role}" if args.role else "browse"
148
+ live = live_mod.Live(os.path.join(_inputs_dir(outdir), key + ".jsonl"), role=args.role)
149
+ args.label = args.label or "browse"
150
+ state = {"build": None}
151
+
152
+ def rebuild() -> str:
153
+ if _write_outputs(args, outdir, replace_build=state["build"], quiet=True) != 0:
154
+ raise RuntimeError(f"could not write outputs to {outdir}")
155
+ state["build"] = args._build_id
156
+ args._model = None
157
+ c = args._counts
158
+ return " ".join([ui.stat(c["hosts"], "host"), ui.stat(c["routes"], "route"),
159
+ ui.stat(c["endpoints"], "API")])
160
+
161
+ def report(e: dict) -> None:
162
+ stamp = ui.style(time.strftime("%H:%M:%S"), "dim")
163
+ if "error" in e:
164
+ print(f" {stamp} " + ui.error(f"rebuild failed: {e['error']}"), flush=True)
165
+ elif "say" in e:
166
+ print(f" {stamp} {e['say']}", flush=True)
167
+ else:
168
+ print(f" {stamp} {ui.style('+' + str(e['added']), 'green')} {e['saved']} saved {e['summary']} "
169
+ + ui.style(f"{e['seconds']:.1f}s", "dim"), flush=True)
170
+
171
+ if args.keep_login:
172
+ tmp = _profile_dir(args.webapp, args.role)
173
+ os.makedirs(tmp, mode=0o700, exist_ok=True)
174
+ else:
175
+ tmp = tempfile.mkdtemp(prefix="proxy2model-browser-")
176
+ extra = ["--window-size=1366,900"] + (["--ignore-certificate-errors"] if args.insecure else [])
177
+ try:
178
+ proc, ws_url = launch_chrome(chrome, tmp, extra, headless=args.headless)
179
+ except RuntimeError as e:
180
+ if not args.keep_login:
181
+ shutil.rmtree(tmp, ignore_errors=True)
182
+ hint = " (is another browse session using this login?)" if args.keep_login else ""
183
+ print(ui.error(f"could not start the browser: {e}{hint}"), file=sys.stderr)
184
+ return 2
185
+
186
+ recorder = Recorder(ws_url, lambda u: in_scope(u, scope), lambda items: live.ingest(items))
187
+ worker = live_mod.Rebuilder(live, rebuild, args.interval, report)
188
+ crawl = None
189
+ try:
190
+ recorder.start()
191
+ recorder.ready.wait(15)
192
+ worker.start()
193
+ _open(ws_url, args.url)
194
+ print("\n".join([
195
+ "", ui.title("browse", args.webapp), "",
196
+ ui.row("target", ui.style(args.url, "bold")),
197
+ ui.row("scope", ", ".join(scope)),
198
+ ui.row("mode", "active: crawling in a background tab while you browse" if args.active
199
+ else "passive: records only what you do"),
200
+ ui.row("role", args.role or ui.style("none", "dim")),
201
+ ui.row("login", f"kept in {tmp} (delete it to log out)" if args.keep_login
202
+ else "fresh browser profile, deleted when you finish"),
203
+ ui.row("output", outdir.rstrip("/") + "/"),
204
+ "", ui.style(" browse the app in the window that opened · close it or press Ctrl-C to finish", "dim"), "",
205
+ ]), flush=True)
206
+ if args.active:
207
+ crawl = threading.Thread(target=_crawl, args=(args, chrome, ws_url, scope, report), daemon=True)
208
+ crawl.start()
209
+ while proc.poll() is None and recorder.is_alive():
210
+ time.sleep(0.3)
211
+ except KeyboardInterrupt:
212
+ pass
213
+ finally:
214
+ recorder.stop.set()
215
+ recorder.join(5)
216
+ if proc.poll() is None:
217
+ proc.terminate()
218
+ try:
219
+ proc.wait(5)
220
+ except subprocess.TimeoutExpired:
221
+ proc.kill()
222
+ if not args.keep_login:
223
+ shutil.rmtree(tmp, ignore_errors=True)
224
+ worker.stop.set()
225
+ worker.join(args.interval + 1)
226
+ if recorder.error:
227
+ print(ui.warn(f"recording stopped early: {recorder.error}"), file=sys.stderr)
228
+ live.flush()
229
+ if not live.saved:
230
+ print(f"\n nothing in scope was recorded ({', '.join(scope)})")
231
+ return 1
232
+ extras = [f"{recorder.assets_skipped} images, fonts and stylesheets and "
233
+ f"{recorder.repeats_skipped} repeat script and preflight loads not recorded"]
234
+ if recorder.frames:
235
+ extras.append(f"{recorder.frames} WebSocket messages (handshakes recorded, messages not)")
236
+ print(f"\n done · {recorder.recorded} requests recorded this session, {live.saved} saved in all · "
237
+ + " · ".join(extras))
238
+ args._model = None
239
+ rc = _write_outputs(args, outdir, replace_build=state["build"])
240
+ if rc == 0:
241
+ print(_visit_next(outdir, args.webapp))
242
+ report_path = os.path.abspath(os.path.join(outdir, "report.html"))
243
+ if args.open and sys.stdout.isatty() and os.path.exists(report_path):
244
+ webbrowser.open("file://" + report_path)
245
+ return rc
246
+
247
+
248
+ def _visit_next(outdir: str, webapp: str, limit: int = 12) -> str:
249
+ """The endpoints the app's code names that this capture never reached: where to go next."""
250
+ try:
251
+ with open(os.path.join(outdir, "model.json"), encoding="utf-8") as f:
252
+ nodes = json.load(f)["nodes"]
253
+ except (OSError, ValueError, KeyError):
254
+ return ""
255
+ todo = sorted(n["label"] for n in nodes
256
+ if n["type"] == "endpoint" and n.get("attrs", {}).get("api_state") == "STATIC_ONLY")
257
+ if not todo:
258
+ return ""
259
+ lines = ["", ui.row("visit next", f"{len(todo)} endpoints the app's code names that you have not reached yet")]
260
+ lines += [" " * 12 + t for t in todo[:limit]]
261
+ if len(todo) > limit:
262
+ lines.append(" " * 12 + ui.style(f"+{len(todo) - limit} more: proxy2model query {webapp} reconcile", "dim"))
263
+ return "\n".join(lines)
264
+
265
+
266
+ def _crawl(args, chrome: str, ws_url: str, scope: list[str], report) -> None:
267
+ """Active mode: crawl in a background tab of the same browser, so it shares the login.
268
+ Its traffic is recorded by the Recorder like any other tab's."""
269
+ from ..ingest.browser import BrowserCrawler
270
+ cfg = CrawlConfig(start=args.url, scope=scope, max_requests=args.max_requests, max_seconds=args.max_seconds,
271
+ read_only=not args.allow_writes, progress=False, insecure=args.insecure)
272
+ report({"say": "active crawl started in a background tab"})
273
+ try:
274
+ res = BrowserCrawler(cfg, chrome, attach=ws_url).run()
275
+ st = res.stats
276
+ report({"say": f"active crawl done: {st['requests']} requests, {st['states']} page states, "
277
+ f"{st['clicks']} clicks"})
278
+ except Exception as e: # the browser was closed mid-crawl, or a page broke it
279
+ report({"say": ui.style(f"active crawl stopped: {e}", "dim")})
@@ -0,0 +1,399 @@
1
+ """`build` and `crawl`: parse, save the input, build the model, write every output."""
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 ..report import shot
12
+ from ..atomic import atomic_write
13
+ from ..ingest.parse import parse_items
14
+ from ..core.model import build, dump_model
15
+ from ..core.context import context_package
16
+ from ..report.animate import render_terminal, write_svg
17
+ from ..report.html import write_html_report
18
+ from ..core.graph import (
19
+ to_graphml, to_graph_json,
20
+ )
21
+ from ..db import store
22
+ from ..export.openapi import to_openapi
23
+ from . import ui
24
+ from .history import _changes_since_previous
25
+ from .common import (
26
+ EXPORTS,
27
+ DEFAULT_INPUT, _init_key, _inputs_dir, _save_input, _stage_input, _drop_if_empty, _clear_inputs, _load_inputs, _scope,
28
+ )
29
+
30
+
31
+ def cmd_build(args) -> int:
32
+ _init_key()
33
+ stats: dict = {}
34
+ outdir = os.path.join(args.out, args.webapp)
35
+ os.makedirs(outdir, exist_ok=True)
36
+ staged = None
37
+ try:
38
+ staged, count = _stage_input(outdir, parse_items(
39
+ args.log, role=args.role, stats=stats,
40
+ skip_tools=tuple((args.skip_tools or "").split(",")),
41
+ only_tools=tuple((args.only_tools or "").split(","))), stats)
42
+ except (OSError, ValueError, SyntaxError) as e:
43
+ _drop_if_empty(outdir)
44
+ if str(args.log).lower().endswith(".har"):
45
+ extra = "\nExpected a HAR file: browser DevTools (Save all as HAR), Hetty, mitmproxy, ZAP or Charles can export one."
46
+ elif str(args.log).lower().endswith(".csv"):
47
+ extra = ("\nExpected a Burp Logger++ CSV export with Method, Host, Request and Response "
48
+ "columns (Request/Response base64 or raw HTTP), or a Caido HTTP history CSV export.")
49
+ elif str(args.log).lower().endswith(".json"):
50
+ extra = ("\nExpected a HAR file or a Caido HTTP history export "
51
+ "(HTTP History, Export, JSON).")
52
+ elif isinstance(e, SyntaxError):
53
+ extra = ("\nThis does not look like a Burp 'Save items' export.\n"
54
+ "In Burp: Proxy → HTTP history → select all → right-click → "
55
+ "Save items → history.xml (keep base64 encoding on).")
56
+ else:
57
+ extra = ""
58
+ print(f"could not read {args.log}: {e}{extra}", file=sys.stderr)
59
+ return 2
60
+ if stats.get("tools"):
61
+ mix = ", ".join(f"{t} {n}" for t, n in sorted(stats["tools"].items(), key=lambda kv: -kv[1]))
62
+ print(f"{'caido sources' if stats.get('format') == 'caido' else 'logger csv'}: {mix}" + (f" · skipped {stats['filtered']} by tool" if stats.get("filtered") else ""),
63
+ file=sys.stderr)
64
+ if "Scanner" in stats["tools"] and not stats.get("filtered") and stats["tools"]["Scanner"] >= 20:
65
+ print(" note: this export includes Scanner traffic (active-scan probes). They show up as "
66
+ "endpoints in the model; add --skip-tools Scanner to model the app without them.",
67
+ file=sys.stderr)
68
+ if not count:
69
+ os.remove(staged)
70
+ _drop_if_empty(outdir)
71
+ if stats.get("items", 0) == 0:
72
+ print(f"{args.log}: no requests found. This does not look like a Burp "
73
+ "'Save items' export, a Logger++ CSV, a HAR or a Caido export.\n"
74
+ "In Burp: Proxy → HTTP history → select all → right-click → "
75
+ "Save items → history.xml (keep base64 encoding on).", file=sys.stderr)
76
+ else:
77
+ print(f"{args.log}: found {stats['items']} item(s) but none could be parsed. "
78
+ "Check the export is a full Burp 'Save items' XML with base64-encoded "
79
+ "requests and responses.", file=sys.stderr)
80
+ return 2
81
+ return _build_from(args, [], stats, staged=staged)
82
+
83
+
84
+ def _build_from(args, exchanges: list, stats: dict, staged: str | None = None) -> int:
85
+ """Everything after parsing: save inputs, merge roles, build, write every output."""
86
+ outdir = os.path.join(args.out, args.webapp)
87
+ os.makedirs(outdir, exist_ok=True)
88
+
89
+ # A role build adds to (or replaces) that role's input; anything else starts over. The new input
90
+ # is in place before the old ones go, so a crash in between never leaves the folder with neither.
91
+ key = args.role or DEFAULT_INPUT
92
+ if staged:
93
+ os.replace(staged, os.path.join(_inputs_dir(outdir), key + ".jsonl"))
94
+ else:
95
+ _save_input(outdir, key, exchanges, stats)
96
+ if args.role is None or args.fresh:
97
+ _clear_inputs(outdir, keep=key)
98
+ else:
99
+ _clear_inputs(outdir, only=DEFAULT_INPUT, keep=key)
100
+ exchanges.clear() # the saved input is the source from here; free the copy
101
+ return _write_outputs(args, outdir)
102
+
103
+
104
+ def _write_outputs(args, outdir: str, replace_build: int | None = None, quiet: bool = False) -> int:
105
+ """Build the model from every saved input and write every output. `browse` calls this on each
106
+ rebuild with `quiet` and the build to overwrite; the build id and counts are left on `args`."""
107
+ all_ex, all_stats, merged = _load_inputs(outdir)
108
+
109
+ m = build(all_ex, name=args.webapp, seed_host=getattr(args, "seed_host", None), scope=_scope(args),
110
+ stats=all_stats)
111
+ all_ex.clear() # the model holds what it needs; keeps peak memory down
112
+ if m.counts()["hosts"] == 0 and not quiet:
113
+ print(ui.warn(f"no captured host matched the scope ({', '.join(m.scope) or 'none'}); every "
114
+ "request was treated as third-party. Check --scope."), file=sys.stderr)
115
+ osint_path = os.path.join(outdir, "osint.json")
116
+ osint_data = _collect_osint(args, m, osint_path)
117
+ try:
118
+ picture = _pick_screenshot(args, outdir)
119
+ except (OSError, ValueError) as e:
120
+ print(f"could not use the screenshot: {e}", file=sys.stderr)
121
+ return 2
122
+
123
+ try:
124
+ with atomic_write(os.path.join(outdir, "model.json")) as f:
125
+ f.writelines(dump_model(m, indent=2))
126
+ db_path = os.path.join(outdir, store.DB_NAME)
127
+ dbs = store.write_db(m, db_path, osint=osint_data, label=getattr(args, "label", None),
128
+ replace_build=replace_build)
129
+ args._build_id, args._counts = dbs.get("build"), m.counts()
130
+ if dbs.get("damaged"):
131
+ print(ui.warn(f"{db_path} was damaged; kept it aside as {dbs['damaged']} and started a "
132
+ "new graph.db (earlier builds are in the kept file)"), file=sys.stderr)
133
+ write_html_report(m, os.path.join(outdir, "report.html"),
134
+ osint=osint_data, lens=args.lens, db_path=db_path, screenshot=picture,
135
+ changes=_changes_since_previous(db_path, dbs.get("build")))
136
+ if picture:
137
+ with atomic_write(os.path.join(outdir, shot.filename(picture[0])), "wb") as f:
138
+ f.write(picture[1])
139
+ with atomic_write(os.path.join(outdir, "context.json")) as f:
140
+ # compact: this file is pasted into an AI, where indentation is tokens
141
+ json.dump(context_package(m, lens=args.lens, cap=args.cap,
142
+ osint=osint_data), f, separators=(",", ":"))
143
+ with atomic_write(os.path.join(outdir, "openapi.json")) as f:
144
+ json.dump(to_openapi(m), f, indent=2)
145
+ exports = os.path.join(outdir, EXPORTS)
146
+ os.makedirs(exports, exist_ok=True)
147
+ write_svg(m, os.path.join(exports, "build.svg"))
148
+ with atomic_write(os.path.join(exports, "graph.json")) as f:
149
+ json.dump(to_graph_json(m), f, indent=2)
150
+ with atomic_write(os.path.join(exports, "graph.graphml")) as f:
151
+ f.write(to_graphml(m))
152
+ for old in ("build.svg", "graph.json", "graph.graphml"): # where versions before 2.0 put them
153
+ if os.path.isfile(os.path.join(outdir, old)):
154
+ os.remove(os.path.join(outdir, old))
155
+ except (OSError, ValueError, sqlite3.Error) as e:
156
+ print(f"could not write outputs to {outdir}: {e}", file=sys.stderr)
157
+ return 2
158
+
159
+ if quiet:
160
+ args._model = m
161
+ return 0
162
+ if getattr(args, "animate", False):
163
+ render_terminal(m, fast=args.no_type)
164
+ print(_summary(args, outdir, m, all_stats, merged))
165
+ return 0
166
+
167
+
168
+ def _summary(args, outdir: str, m, stats: dict, merged: list[str]) -> str:
169
+ """What the build read, what the model holds, where everything went, and what to run next."""
170
+ c = m.counts()
171
+ st = c["api_state"]
172
+ source = getattr(args, "log", None) or getattr(args, "url", None) or "saved inputs"
173
+ # input names are roles, except the untagged ones; browser sessions save as browse[-ROLE]
174
+ roles = sorted({n.split("-", 1)[1] if n.startswith(("browse-", "live-")) else n for n in merged}
175
+ - {DEFAULT_INPUT, "browse", "live"})
176
+ lines = ["", ui.title("build", args.webapp), "",
177
+ ui.row("input", str(source))]
178
+ if roles:
179
+ lines.append(ui.row("roles", ", ".join(roles) + ui.style(" (merged into one model)", "dim")))
180
+ if stats.get("skipped"):
181
+ lines.append(ui.row("skipped", f"{stats['skipped']} unparseable items"))
182
+ lines += [
183
+ ui.row("scope", ", ".join(m.scope) or "none"),
184
+ "",
185
+ ui.row("model", " ".join([ui.stat(stats.get("parsed", 0), "request"), ui.stat(c["hosts"], "host"), ui.stat(c["routes"], "route"),
186
+ ui.stat(c["endpoints"], "API"), ui.stat(c["parameters"], "param"),
187
+ ui.stat(c["unknowns"], "unknown")])),
188
+ ui.row("APIs", f"{st.get('RUNTIME_ONLY', 0)} seen in traffic only · "
189
+ f"{st.get('STATIC_ONLY', 0)} in code only · {st.get('BOTH', 0)} both"),
190
+ "",
191
+ ui.row("output", ui.style(outdir.rstrip("/") + "/", "bold") + "\n" + ui.files([
192
+ ("report.html", "open in a browser"),
193
+ ("model.json", "the model and the request behind every claim"),
194
+ ("openapi.json", "the observed API, OpenAPI 3.0"),
195
+ ("context.json", "what to hand an AI instead of raw traffic"),
196
+ ("graph.db", "the model as a SQLite graph"),
197
+ (EXPORTS + "/", "graph.json · graph.graphml · build.svg"),
198
+ ])),
199
+ "",
200
+ ui.row("next", f"proxy2model query {args.webapp} help"),
201
+ ]
202
+ return "\n".join(lines)
203
+
204
+
205
+ def _pick_screenshot(args, outdir: str):
206
+ """A picture for the overview: the one given, else the crawl's, else one saved by an earlier build."""
207
+ if getattr(args, "screenshot", None):
208
+ return shot.load(args.screenshot)
209
+ return getattr(args, "_shot", None) or shot.existing(outdir)
210
+
211
+
212
+ def _primary_host(m) -> str | None:
213
+ """The first-party host with the most evidence — the one worth probing."""
214
+ hosts = [n for n in m.nodes.values() if n.type == "host"]
215
+ if not hosts:
216
+ return None
217
+ return max(sorted(hosts, key=lambda n: n.label), key=lambda n: len(n.evidence)).label
218
+
219
+
220
+ def _probe_host(host: str | None) -> str | None:
221
+ """A hostname OSINT can meaningfully look up, or None."""
222
+ if not host:
223
+ return None
224
+ host = host.lower().split(":")[0]
225
+ if (not re.match(r"^[a-z0-9.\-]{1,253}$", host) or "." not in host
226
+ or re.match(r"^\d{1,3}(\.\d{1,3}){3}$", host)
227
+ or host.endswith((".local", ".localhost", ".internal", ".test", ".invalid",
228
+ ".example", ".lan", ".corp", ".home"))
229
+ or host in ("example.com", "example.net", "example.org")
230
+ or host.endswith((".example.com", ".example.net", ".example.org"))):
231
+ return None
232
+ return host
233
+
234
+
235
+ def _collect_osint(args, m, osint_path: str) -> dict | None:
236
+ """External recon runs only with --osint and never fails a build.
237
+
238
+ Live results replace the stored osint.json; if the lookup is skipped or
239
+ fails, the last saved run is reused so the graph keeps its recon layer.
240
+ """
241
+ saved = None
242
+ if os.path.exists(osint_path):
243
+ try:
244
+ with open(osint_path, encoding="utf-8") as f:
245
+ saved = json.load(f)
246
+ except (OSError, ValueError):
247
+ saved = None
248
+ if not getattr(args, "osint", False) or os.environ.get("PROXY2MODEL_OFFLINE") == "1":
249
+ return saved
250
+ host = _probe_host(_primary_host(m))
251
+ if host is None:
252
+ print("osint: skipped (no public hostname among first-party hosts)", file=sys.stderr)
253
+ return saved
254
+ from ..recon import osint as osint_mod
255
+ print(f"osint: probing {host} — DNS, TLS, headers, CT logs, RDAP "
256
+ f"(PROXY2MODEL_OFFLINE=1 forces offline)", file=sys.stderr)
257
+ try:
258
+ result = osint_mod.run(host, timeout=args.osint_timeout, want_ports=False,
259
+ want_subdomains=True).to_dict()
260
+ with atomic_write(osint_path) as f:
261
+ json.dump(result, f, indent=2)
262
+ return result
263
+ except Exception as e: # fail soft: recon is optional
264
+ print(f"osint: failed ({e}); continuing without fresh recon", file=sys.stderr)
265
+ return saved
266
+
267
+
268
+ def _wants_screenshot(args, headers, cookies, journeys, local_storage) -> bool:
269
+ """Only a signed-out start page is captured on its own; a signed-in page may show someone's data."""
270
+ return not (args.no_screenshot or args.screenshot or args.auth_login or cookies or journeys
271
+ or local_storage or args.auth_storage
272
+ or any(k.lower() in ("authorization", "cookie") for k in headers))
273
+
274
+
275
+ def cmd_crawl(args) -> int:
276
+ from ..ingest import crawl as crawl_mod
277
+ from ..ingest.parse import parse_elements
278
+ _init_key()
279
+ if not (args.yes or os.environ.get("PROXY2MODEL_CRAWL_CONSENT") == "1"):
280
+ print(
281
+ "crawl sends live requests to the target (GET only unless --submit-forms).\n"
282
+ "Only crawl applications you are authorized to test.\n"
283
+ f" target: {args.url}\n"
284
+ "Re-run with --yes to confirm you have authorization.", file=sys.stderr)
285
+ return 2
286
+ headers = {}
287
+ for h in args.header or []:
288
+ k, sep, v = h.partition(":")
289
+ if not sep or not k.strip():
290
+ print(f"--header must look like 'Name: value' (got {h!r})", file=sys.stderr)
291
+ return 2
292
+ headers[k.strip()] = v.strip()
293
+ cookies = {}
294
+ for c in (args.cookie or "").split(";"):
295
+ k, sep, v = c.strip().partition("=")
296
+ if sep and k:
297
+ cookies[k] = v
298
+ auth_body = args.auth_body
299
+ if auth_body and auth_body.startswith("@"):
300
+ try:
301
+ with open(auth_body[1:], encoding="utf-8") as f:
302
+ auth_body = f.read()
303
+ except OSError as e:
304
+ print(f"could not read {auth_body[1:]}: {e}", file=sys.stderr)
305
+ return 2
306
+ if args.auth_login and not auth_body:
307
+ print("--auth-login needs --auth-body (JSON, or @file)", file=sys.stderr)
308
+ return 2
309
+ scope = [x.strip() for v in (args.scope or []) for x in v.split(",") if x.strip()]
310
+ from ..ingest import journey as jy
311
+ journeys = []
312
+ for path in args.journey or []:
313
+ try:
314
+ journeys.append(jy.load(path))
315
+ except jy.JourneyError as e:
316
+ print(f"journey: {e}", file=sys.stderr)
317
+ return 2
318
+ journey_vars = {}
319
+ for kv in args.var or []:
320
+ k, sep, v = kv.partition("=")
321
+ if not sep or not k:
322
+ print(f"--var must look like name=value (got {kv!r})", file=sys.stderr)
323
+ return 2
324
+ journey_vars[k] = v
325
+ if args.journey_only and not journeys:
326
+ print("--journey-only needs at least one --journey FILE", file=sys.stderr)
327
+ return 2
328
+ local_storage = {}
329
+ for kv in args.local_storage or []:
330
+ k, sep, v = kv.partition("=")
331
+ if not sep or not k:
332
+ print(f"--local-storage must look like key=value (got {kv!r})", file=sys.stderr)
333
+ return 2
334
+ local_storage[k] = v
335
+ try:
336
+ cfg = crawl_mod.CrawlConfig(
337
+ start=args.url, scope=scope, include_subdomains=args.include_subdomains,
338
+ max_requests=args.max_requests, max_depth=args.depth, delay=args.delay,
339
+ timeout=args.timeout, threads=args.threads, headers=headers, cookies=cookies,
340
+ probe_js=not args.no_probe_js, submit_forms=args.submit_forms,
341
+ respect_robots=args.respect_robots, insecure=args.insecure,
342
+ auth_login=args.auth_login, auth_body=auth_body, auth_token=args.auth_token,
343
+ auth_scheme=args.auth_scheme, max_seconds=args.max_seconds, browser=args.browser,
344
+ chrome=args.chrome, max_clicks=args.max_clicks, no_interact=args.no_interact,
345
+ no_forms=args.no_forms, read_only=args.read_only, headful=args.headful,
346
+ auth_storage=args.auth_storage, local_storage=local_storage,
347
+ per_template_cap=args.per_template_cap, journeys=journeys, journey_vars=journey_vars,
348
+ journey_only=args.journey_only, max_assets=args.max_assets,
349
+ screenshot=_wants_screenshot(args, headers, cookies, journeys, local_storage))
350
+ crawler = crawl_mod.make_crawler(cfg)
351
+ except ValueError as e:
352
+ print(f"crawl: {e}", file=sys.stderr)
353
+ return 2
354
+ browser_mode = bool(getattr(crawler, "chrome", None))
355
+ mode = ("a real browser (JavaScript on; clicks and benign form fills; "
356
+ + ("read-only" if cfg.read_only else "in-scope POST/PUT/DELETE the app itself makes are allowed")
357
+ + ")") if browser_mode else ("static: GET only" if not cfg.submit_forms
358
+ else "static: GET + benign form posts")
359
+ print(f"crawl: {args.url} scope: {', '.join(crawler.scope)} mode: {mode} "
360
+ f"(budget {cfg.max_requests or 'unlimited'} requests, depth {cfg.max_depth})", file=sys.stderr)
361
+ try:
362
+ res = crawler.run()
363
+ except KeyboardInterrupt:
364
+ print("crawl: interrupted; building from what was fetched", file=sys.stderr)
365
+ res = crawl_mod.CrawlResult(crawler.items, crawler.stats, crawler.forms, crawler.scope)
366
+ st = res.stats
367
+ print(f"crawl: {st['requests']} requests ({st['pages']} pages, {st['assets']} assets, "
368
+ f"{st['js_probes']} API/code calls) · {st['forms_found']} forms · "
369
+ f"skipped: {st['skipped_out_of_scope']} out of scope, {st['skipped_unsafe']} unsafe-looking, "
370
+ f"{st['skipped_duplicate']} duplicate, {st['skipped_cap']} over a cap · "
371
+ f"{st['errors']} errors", file=sys.stderr)
372
+ if journeys:
373
+ print(f"crawl: journeys — {st['journey_steps'] - st['journey_failed']} of {st['journey_steps']} "
374
+ f"steps ran cleanly", file=sys.stderr)
375
+ if st.get("browser"):
376
+ print(f"crawl: browser — {st['states']} page states, {st['clicks']} clicks, "
377
+ f"{st['forms_submitted']} forms submitted, blocked before sending: "
378
+ f"{st['blocked_scope']} out of scope, {st['blocked_unsafe']} unsafe-looking, "
379
+ f"{st['blocked_method']} non-GET (read-only), {st.get('blocked_asset_cap', 0)} over the asset cap, "
380
+ f"{st['dialogs_dismissed']} dialogs dismissed",
381
+ file=sys.stderr)
382
+ if st["js_refs_not_requested"]:
383
+ print(f"crawl: {st['js_refs_not_requested']} code reference(s) name a non-GET method "
384
+ "and were not sent", file=sys.stderr)
385
+ if args.save_xml:
386
+ try:
387
+ with atomic_write(args.save_xml) as f:
388
+ f.write(crawl_mod.to_burp_xml(res.items))
389
+ print(f"wrote {args.save_xml} (Burp 'Save items' format; holds raw values, "
390
+ "unlike every proxy2model output)", file=sys.stderr)
391
+ except OSError as e:
392
+ print(f"could not write {args.save_xml}: {e}", file=sys.stderr)
393
+ args._shot = ("image/jpeg", res.screenshot) if getattr(res, "screenshot", None) else None
394
+ pstats: dict = {}
395
+ exchanges = list(parse_elements(res.items, source="crawl", role=args.role, stats=pstats))
396
+ if not exchanges:
397
+ print("crawl: nothing was fetched — is the target reachable?", file=sys.stderr)
398
+ return 2
399
+ return _build_from(args, exchanges, pstats)