proxy2model 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- proxy2model/__init__.py +19 -0
- proxy2model/__main__.py +6 -0
- proxy2model/atomic.py +68 -0
- proxy2model/cli/__init__.py +3 -0
- proxy2model/cli/analyze.py +501 -0
- proxy2model/cli/app.py +408 -0
- proxy2model/cli/browse.py +279 -0
- proxy2model/cli/build.py +399 -0
- proxy2model/cli/common.py +190 -0
- proxy2model/cli/history.py +224 -0
- proxy2model/cli/mcp.py +184 -0
- proxy2model/cli/ui.py +62 -0
- proxy2model/core/__init__.py +1 -0
- proxy2model/core/changes.py +124 -0
- proxy2model/core/context.py +571 -0
- proxy2model/core/diff.py +330 -0
- proxy2model/core/domains.py +98 -0
- proxy2model/core/graph.py +562 -0
- proxy2model/core/methodology.py +343 -0
- proxy2model/core/model.py +998 -0
- proxy2model/core/stack.py +218 -0
- proxy2model/data/fonts/LICENSES.md +16 -0
- proxy2model/data/fonts/inter-latin-wght-normal.woff2 +0 -0
- proxy2model/data/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
- proxy2model/data/fonts/nippo-200.woff2 +0 -0
- proxy2model/data/fonts/nippo-300.woff2 +0 -0
- proxy2model/data/fonts/nippo-400.woff2 +0 -0
- proxy2model/data/fonts/nippo-500.woff2 +0 -0
- proxy2model/data/fonts/nippo-700.woff2 +0 -0
- proxy2model/data/public_suffix_list.dat +16497 -0
- proxy2model/db/__init__.py +1 -0
- proxy2model/db/bql.py +710 -0
- proxy2model/db/bql_js.py +278 -0
- proxy2model/db/store.py +547 -0
- proxy2model/export/__init__.py +1 -0
- proxy2model/export/openapi.py +248 -0
- proxy2model/ingest/__init__.py +1 -0
- proxy2model/ingest/browse.py +181 -0
- proxy2model/ingest/browser.py +1058 -0
- proxy2model/ingest/crawl.py +673 -0
- proxy2model/ingest/extract.py +208 -0
- proxy2model/ingest/infer.py +75 -0
- proxy2model/ingest/journey.py +148 -0
- proxy2model/ingest/live.py +96 -0
- proxy2model/ingest/parse.py +1275 -0
- proxy2model/ingest/redact.py +535 -0
- proxy2model/py.typed +0 -0
- proxy2model/recon/__init__.py +1 -0
- proxy2model/recon/osint.py +607 -0
- proxy2model/report/__init__.py +1 -0
- proxy2model/report/animate.py +163 -0
- proxy2model/report/dash.py +469 -0
- proxy2model/report/fonts.py +51 -0
- proxy2model/report/html.py +988 -0
- proxy2model/report/inventory.py +258 -0
- proxy2model/report/map.py +515 -0
- proxy2model/report/pages.py +537 -0
- proxy2model/report/query.py +306 -0
- proxy2model/report/shot.py +55 -0
- proxy2model/report/view.py +213 -0
- proxy2model-2.0.0.dist-info/METADATA +148 -0
- proxy2model-2.0.0.dist-info/RECORD +66 -0
- proxy2model-2.0.0.dist-info/WHEEL +5 -0
- proxy2model-2.0.0.dist-info/entry_points.txt +2 -0
- proxy2model-2.0.0.dist-info/licenses/LICENSE +201 -0
- proxy2model-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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")})
|
proxy2model/cli/build.py
ADDED
|
@@ -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)
|