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,673 @@
1
+ """
2
+ A crawler, so the model can be built straight from a running web app with no
3
+ proxy and no Burp export.
4
+
5
+ It does what a browser-driven spider does, with the standard library:
6
+
7
+ * follows links, frames, redirects and GET forms, sending a Referer like a
8
+ browser so page -> request edges (CALLS / NAVIGATES_TO) appear in the model;
9
+ * fetches the scripts and styles a page pulls in, then reads the endpoints the
10
+ JavaScript names and requests the concrete GET ones (the paths a single-page
11
+ app only reveals in its bundle);
12
+ * keeps cookies, takes extra headers, and can log in once and carry the token,
13
+ so authenticated surface is reachable (one crawl per role, like `--role`);
14
+ * records every request and response as a Burp-style <item>, in memory. They go
15
+ through exactly the same parser and redaction as a Burp export, so nothing raw
16
+ is ever written by the crawl itself.
17
+
18
+ What it deliberately does not do: it never leaves the scope you give it, never
19
+ sends anything but GET unless you pass --submit-forms (and then never to a form
20
+ with a password field), skips links that look destructive (logout, delete, ...),
21
+ rate-limits itself, caps requests, depth, body size and variants of one template,
22
+ and does not run until you confirm you are authorised to test the target.
23
+
24
+ It does not execute JavaScript. A page that builds its links in the browser is
25
+ covered through the endpoints its bundle names, not by clicking.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import base64
31
+ import gzip
32
+ import http.client
33
+ import json
34
+ import re
35
+ import ssl
36
+ import sys
37
+ import threading
38
+ import time
39
+ import urllib.parse
40
+ import zlib
41
+ from concurrent.futures import ThreadPoolExecutor
42
+ from dataclasses import dataclass, field
43
+ from html.parser import HTMLParser
44
+ from xml.etree.ElementTree import Element, SubElement
45
+
46
+ from .. import __version__
47
+ from ..core.domains import registrable_domain
48
+ from .extract import extract_js_refs
49
+ from .redact import route_template, template_path
50
+
51
+ _UNSAFE = re.compile(
52
+ r"(?i)(log-?out|sign-?out|log-?off|delete|remove|destroy|unsubscribe|deactivate|"
53
+ r"erase|erasure|reset-?db|shutdown|drop|purge|wipe|truncate|factory-?reset|"
54
+ r"set-?up|install|init-?db|create-?db|database-?reset)")
55
+ _STATIC_ASSET = re.compile(r"(?i)\.(?:png|jpe?g|gif|svg|ico|webp|avif|bmp|woff2?|ttf|otf|eot|mp4|webm|mp3|wav|pdf|zip)$")
56
+ _REASONS = {200: "OK", 201: "Created", 204: "No Content", 301: "Moved Permanently", 302: "Found",
57
+ 303: "See Other", 304: "Not Modified", 307: "Temporary Redirect", 308: "Permanent Redirect",
58
+ 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found",
59
+ 405: "Method Not Allowed", 429: "Too Many Requests", 500: "Internal Server Error",
60
+ 502: "Bad Gateway", 503: "Service Unavailable"}
61
+ _PAGE_TAGS = {"a": "href", "area": "href", "iframe": "src", "frame": "src"}
62
+ _ASSET_TAGS = {"script": "src", "link": "href", "img": "src", "source": "src", "video": "src",
63
+ "audio": "src", "embed": "src", "track": "src"}
64
+
65
+
66
+ def in_scope(url: str, scope: list[str]) -> bool:
67
+ """Whether a URL is in scope: `host`, `host:port`, or `*.domain` (the domain and every subdomain)."""
68
+ u = urllib.parse.urlsplit(url)
69
+ if u.scheme not in ("http", "https") or not u.hostname:
70
+ return False
71
+ host = u.hostname.lower()
72
+ port = u.port or (443 if u.scheme == "https" else 80)
73
+ for s in scope:
74
+ if s.startswith("*."):
75
+ if host == s[2:] or host.endswith(s[1:]):
76
+ return True
77
+ elif ":" in s and not s.endswith("]"):
78
+ sh, _, sp = s.rpartition(":")
79
+ if host == sh and str(port) == sp:
80
+ return True
81
+ elif host == s:
82
+ return True
83
+ return False
84
+
85
+
86
+ @dataclass
87
+ class CrawlConfig:
88
+ start: str
89
+ scope: list[str] = field(default_factory=list) # extra hosts / host:port
90
+ include_subdomains: bool = False
91
+ max_requests: int = 3000 # 0 = no limit
92
+ max_depth: int = 12
93
+ max_assets: int = 2000
94
+ max_seconds: float = 0 # wall-clock limit for the whole crawl; 0 = none
95
+ delay: float = 0.1
96
+ timeout: float = 10.0
97
+ threads: int = 1
98
+ headers: dict = field(default_factory=dict)
99
+ cookies: dict = field(default_factory=dict)
100
+ probe_js: bool = True
101
+ submit_forms: bool = False
102
+ respect_robots: bool = False
103
+ insecure: bool = False
104
+ user_agent: str = f"proxy2model-crawler/{__version__}"
105
+ per_template_cap: int = 25 # variants of /items/{id}
106
+ per_path_query_cap: int = 15 # variants of /search?q=...
107
+ max_body: int = 5 * 1024 * 1024
108
+ auth_login: str | None = None # URL to POST credentials to
109
+ auth_body: str | None = None # JSON body for it
110
+ auth_token: str | None = None # dotted path to the token in the JSON reply
111
+ auth_header: str = "Authorization"
112
+ auth_scheme: str = "Bearer"
113
+ progress: bool = True
114
+ # browser mode (a real Chrome driven over DevTools; see browser.py)
115
+ browser: str = "auto" # auto | on | off
116
+ chrome: str | None = None
117
+ max_clicks: int = 25 # per page state
118
+ no_interact: bool = False # load pages, do not click or fill
119
+ no_forms: bool = False # fill fields but do not submit forms
120
+ read_only: bool = False # block every non-GET the browser would send
121
+ headful: bool = False
122
+ auth_storage: str | None = None # put the login token in localStorage[KEY] and cookie KEY
123
+ local_storage: dict = field(default_factory=dict)
124
+ # scripted journeys (see journey.py)
125
+ journeys: list = field(default_factory=list)
126
+ journey_vars: dict = field(default_factory=dict)
127
+ journey_only: bool = False
128
+ screenshot: bool = False # browser: capture the start page for the report
129
+
130
+
131
+ @dataclass
132
+ class Task:
133
+ method: str
134
+ url: str
135
+ depth: int
136
+ referer: str | None
137
+ kind: str # seed | page | asset | js | form | redirect | sitemap
138
+ body: bytes | None = None
139
+ ctype: str | None = None
140
+ hops: int = 0
141
+
142
+
143
+ @dataclass
144
+ class CrawlResult:
145
+ items: list
146
+ stats: dict
147
+ forms: list
148
+ scope: list[str]
149
+ screenshot: bytes | None = None # the start page, when a browser crawl took one
150
+
151
+
152
+ class _Page(HTMLParser):
153
+ """Links, frames, assets and forms out of an HTML document."""
154
+
155
+ def __init__(self):
156
+ super().__init__(convert_charrefs=True)
157
+ self.base: str | None = None
158
+ self.pages: list[str] = []
159
+ self.assets: list[str] = []
160
+ self.forms: list[dict] = []
161
+ self._form: dict | None = None
162
+
163
+ def handle_starttag(self, tag, attrs):
164
+ a = {k: (v or "") for k, v in attrs}
165
+ if tag == "base" and a.get("href"):
166
+ self.base = a["href"]
167
+ elif tag in _PAGE_TAGS and a.get(_PAGE_TAGS[tag]):
168
+ self.pages.append(a[_PAGE_TAGS[tag]])
169
+ elif tag in _ASSET_TAGS and a.get(_ASSET_TAGS[tag]):
170
+ if tag == "link" and a.get("rel", "").lower() not in ("stylesheet", "icon", "shortcut icon",
171
+ "manifest", "modulepreload", "preload"):
172
+ return
173
+ self.assets.append(a[_ASSET_TAGS[tag]])
174
+ elif tag == "meta" and a.get("http-equiv", "").lower() == "refresh":
175
+ m = re.search(r"url\s*=\s*['\"]?([^'\";]+)", a.get("content", ""), re.I)
176
+ if m:
177
+ self.pages.append(m.group(1).strip())
178
+ elif tag == "form":
179
+ self._form = {"action": a.get("action", ""), "method": (a.get("method") or "GET").upper(),
180
+ "inputs": []}
181
+ self.forms.append(self._form)
182
+ elif tag in ("input", "select", "textarea", "button") and self._form is not None and a.get("name"):
183
+ self._form["inputs"].append((a["name"], (a.get("type") or tag).lower(), a.get("value", "")))
184
+
185
+ def handle_endtag(self, tag):
186
+ if tag == "form":
187
+ self._form = None
188
+
189
+
190
+ def _burp_mime(ct: str) -> str:
191
+ ct = (ct or "").split(";")[0].strip().lower()
192
+ return {"text/html": "HTML", "application/json": "JSON", "text/css": "CSS",
193
+ "application/javascript": "script", "text/javascript": "script", "image/png": "PNG",
194
+ "image/jpeg": "JPEG", "image/svg+xml": "XML", "application/xml": "XML", "text/xml": "XML",
195
+ "text/plain": "text", "application/pdf": "PDF"}.get(ct, "text" if ct.startswith("text/") else "")
196
+
197
+
198
+ def _decode(headers: dict, body: bytes) -> bytes:
199
+ enc = headers.get("content-encoding", "").lower()
200
+ try:
201
+ if "gzip" in enc:
202
+ return gzip.decompress(body)
203
+ if "deflate" in enc:
204
+ try:
205
+ return zlib.decompress(body)
206
+ except zlib.error:
207
+ return zlib.decompress(body, -zlib.MAX_WBITS)
208
+ except (OSError, zlib.error, EOFError):
209
+ pass
210
+ return body
211
+
212
+
213
+ class Crawler:
214
+ def __init__(self, cfg: CrawlConfig):
215
+ u = urllib.parse.urlsplit(cfg.start)
216
+ if u.scheme not in ("http", "https") or not u.hostname:
217
+ raise ValueError(f"not an http(s) URL: {cfg.start!r}")
218
+ self.cfg = cfg
219
+ self.start = u
220
+ self.scope = self._build_scope(u, cfg)
221
+ self.jar: dict[str, dict[str, str]] = {}
222
+ if cfg.cookies:
223
+ self.jar[u.hostname.lower()] = dict(cfg.cookies)
224
+ self.headers = dict(cfg.headers)
225
+ self.items: list[Element] = []
226
+ self.seen: set = set()
227
+ self.template_count: dict = {}
228
+ self.query_count: dict = {}
229
+ self.forms: list[dict] = []
230
+ self.stats = {"requests": 0, "pages": 0, "assets": 0, "js_probes": 0, "redirects": 0,
231
+ "errors": 0, "skipped_out_of_scope": 0, "skipped_unsafe": 0,
232
+ "skipped_duplicate": 0, "skipped_cap": 0, "forms_found": 0,
233
+ "forms_submitted": 0, "js_refs_not_requested": 0, "robots_skipped": 0}
234
+ self._rl = threading.Lock()
235
+ self._last = 0.0
236
+ self._disallow: list[str] = []
237
+ self._ctx = ssl._create_unverified_context() if cfg.insecure else ssl.create_default_context()
238
+ self.stats.update({"journey_steps": 0, "journey_failed": 0})
239
+ self.journey_log: list[str] = []
240
+
241
+ # ---------------------------------------------------------------- scope --
242
+ @staticmethod
243
+ def _build_scope(u, cfg) -> list[str]:
244
+ port = u.port or (443 if u.scheme == "https" else 80)
245
+ scope = [f"{u.hostname.lower()}:{port}"]
246
+ for s in cfg.scope:
247
+ s = s.strip().lower()
248
+ if s:
249
+ scope.append(s)
250
+ if cfg.include_subdomains:
251
+ scope.append("*." + registrable_domain(u.hostname.lower()))
252
+ return scope
253
+
254
+ def in_scope(self, url: str) -> bool:
255
+ return in_scope(url, self.scope)
256
+
257
+ # ----------------------------------------------------------------- fetch --
258
+ def _wait(self) -> None:
259
+ with self._rl:
260
+ gap = self._last + self.cfg.delay - time.monotonic()
261
+ if gap > 0:
262
+ time.sleep(gap)
263
+ self._last = time.monotonic()
264
+
265
+ def _cookie_header(self, host: str) -> str:
266
+ return "; ".join(f"{k}={v}" for k, v in self.jar.get(host, {}).items())
267
+
268
+ def _store_cookies(self, host: str, resp_headers: list) -> None:
269
+ for k, v in resp_headers:
270
+ if k.lower() != "set-cookie":
271
+ continue
272
+ pair = v.split(";", 1)[0]
273
+ name, eq, val = pair.partition("=")
274
+ if not eq or not name.strip():
275
+ continue
276
+ attrs = v.lower()
277
+ jar = self.jar.setdefault(host, {})
278
+ if not val.strip() or "max-age=0" in attrs:
279
+ jar.pop(name.strip(), None)
280
+ else:
281
+ jar[name.strip()] = val.strip()
282
+
283
+ def fetch(self, task: Task):
284
+ """One request. Returns (Element, status, resp_headers dict, decoded body bytes) or None."""
285
+ u = urllib.parse.urlsplit(task.url)
286
+ host = u.hostname.lower()
287
+ port = u.port or (443 if u.scheme == "https" else 80)
288
+ path = (u.path or "/") + ("?" + u.query if u.query else "")
289
+ hdr = {"Host": u.netloc, "User-Agent": self.cfg.user_agent,
290
+ "Accept": "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8",
291
+ "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate",
292
+ "Connection": "close"}
293
+ if task.referer:
294
+ hdr["Referer"] = task.referer
295
+ hdr.update(self.headers)
296
+ ck = self._cookie_header(host)
297
+ if ck:
298
+ hdr["Cookie"] = ck
299
+ if task.body is not None:
300
+ hdr["Content-Type"] = task.ctype or "application/x-www-form-urlencoded"
301
+ hdr["Content-Length"] = str(len(task.body))
302
+ raw_req = (f"{task.method} {path} HTTP/1.1\r\n"
303
+ + "".join(f"{k}: {v}\r\n" for k, v in hdr.items()) + "\r\n").encode("latin-1", "replace")
304
+ raw_req += task.body or b""
305
+ self._wait()
306
+ try:
307
+ cls = http.client.HTTPSConnection if u.scheme == "https" else http.client.HTTPConnection
308
+ kw = {"timeout": self.cfg.timeout}
309
+ if u.scheme == "https":
310
+ kw["context"] = self._ctx
311
+ conn = cls(u.hostname, port, **kw)
312
+ conn.request(task.method, path, body=task.body,
313
+ headers={k: v for k, v in hdr.items() if k != "Host"} | {"Host": u.netloc})
314
+ resp = conn.getresponse()
315
+ data = resp.read(self.cfg.max_body + 1)
316
+ truncated = len(data) > self.cfg.max_body
317
+ data = data[: self.cfg.max_body]
318
+ rheaders = resp.getheaders()
319
+ status = resp.status
320
+ conn.close()
321
+ except (OSError, http.client.HTTPException, ssl.SSLError):
322
+ self.stats["errors"] += 1
323
+ return None
324
+ self._store_cookies(host, rheaders)
325
+ kept = [(k, v) for k, v in rheaders if k.lower() not in ("transfer-encoding", "content-length")]
326
+ kept.append(("Content-Length", str(len(data))))
327
+ head = (f"HTTP/1.1 {status} {_REASONS.get(status, resp.reason)}\r\n"
328
+ + "".join(f"{k}: {v}\r\n" for k, v in kept) + "\r\n")
329
+ raw_resp = head.encode("latin-1", "replace") + data
330
+ low = {k.lower(): v for k, v in rheaders}
331
+ item = Element("item")
332
+ for tag, val in (("method", task.method), ("host", host), ("port", str(port)),
333
+ ("protocol", u.scheme), ("path", path), ("status", str(status)),
334
+ ("mimetype", _burp_mime(low.get("content-type", "")))):
335
+ SubElement(item, tag).text = val
336
+ rq = SubElement(item, "request", {"base64": "true"})
337
+ rq.text = base64.b64encode(raw_req).decode()
338
+ rs = SubElement(item, "response", {"base64": "true"})
339
+ rs.text = base64.b64encode(raw_resp).decode()
340
+ if truncated:
341
+ SubElement(item, "comment").text = "body truncated by proxy2model crawler"
342
+ return item, status, low, _decode(low, data)
343
+
344
+ # ------------------------------------------------------------- planning --
345
+ def _normalise(self, base: str, link: str) -> str | None:
346
+ link = (link or "").strip()
347
+ if not link or link.startswith(("#", "javascript:", "mailto:", "tel:", "data:", "blob:")):
348
+ return None
349
+ url = urllib.parse.urljoin(base, link)
350
+ url, _ = urllib.parse.urldefrag(url)
351
+ return url if url.startswith(("http://", "https://")) else None
352
+
353
+ def _admit(self, method: str, url: str, kind: str) -> bool:
354
+ """Scope, safety, duplicate and variant caps. Counts why a URL was dropped."""
355
+ if not self.in_scope(url):
356
+ self.stats["skipped_out_of_scope"] += 1
357
+ return False
358
+ u = urllib.parse.urlsplit(url)
359
+ if _UNSAFE.search(u.path) or _UNSAFE.search(u.query):
360
+ self.stats["skipped_unsafe"] += 1
361
+ return False
362
+ if self.cfg.respect_robots and any(u.path.startswith(d) for d in self._disallow if d):
363
+ self.stats["robots_skipped"] += 1
364
+ return False
365
+ key = (method, u.scheme, u.netloc.lower(), u.path, u.query)
366
+ if key in self.seen:
367
+ self.stats["skipped_duplicate"] += 1
368
+ return False
369
+ tkey = (method, u.netloc.lower(), route_template(u.path + ("?" + u.query if u.query else ""),
370
+ template_path(u.path)))
371
+ qkey = (method, u.netloc.lower(), u.path)
372
+ if self.template_count.get(tkey, 0) >= self.cfg.per_template_cap or \
373
+ (u.query and self.query_count.get(qkey, 0) >= self.cfg.per_path_query_cap):
374
+ self.stats["skipped_cap"] += 1
375
+ return False
376
+ self.seen.add(key)
377
+ self.template_count[tkey] = self.template_count.get(tkey, 0) + 1
378
+ if u.query:
379
+ self.query_count[qkey] = self.query_count.get(qkey, 0) + 1
380
+ return True
381
+
382
+ def _sample(self, inputs) -> list[tuple[str, str]]:
383
+ out = []
384
+ for name, typ, value in inputs:
385
+ if typ in ("submit", "button", "image", "reset", "file"):
386
+ continue
387
+ if typ in ("checkbox", "radio") and not value:
388
+ continue
389
+ out.append((name, value or {"number": "1", "email": "test@example.invalid"}.get(typ, "test")))
390
+ return out
391
+
392
+ def _discover(self, task: Task, status: int, low: dict, body: bytes) -> list[Task]:
393
+ ct = low.get("content-type", "").lower()
394
+ new: list[Task] = []
395
+ text = body.decode("utf-8", "replace") if body else ""
396
+ base = task.url
397
+ is_html = "html" in ct
398
+ is_js = "javascript" in ct or task.url.split("?")[0].endswith((".js", ".mjs"))
399
+
400
+ if 300 <= status < 400 and low.get("location"):
401
+ dest = self._normalise(task.url, low["location"])
402
+ if dest and task.hops < 5 and self._admit("GET", dest, "redirect"):
403
+ self.stats["redirects"] += 1
404
+ new.append(Task("GET", dest, task.depth, task.referer, "redirect", hops=task.hops + 1))
405
+ return new
406
+ if not (200 <= status < 300) or not text:
407
+ return new
408
+
409
+ if is_html:
410
+ page = _Page()
411
+ try:
412
+ page.feed(text)
413
+ except Exception:
414
+ pass
415
+ if page.base:
416
+ base = urllib.parse.urljoin(task.url, page.base)
417
+ if task.depth < self.cfg.max_depth:
418
+ for link in page.pages:
419
+ dest = self._normalise(base, link)
420
+ if dest and self._admit("GET", dest, "page"):
421
+ new.append(Task("GET", dest, task.depth + 1, task.url, "page"))
422
+ for link in page.assets:
423
+ dest = self._normalise(base, link)
424
+ if dest and (self.stats["assets"] + sum(1 for t in new if t.kind == "asset")
425
+ < self.cfg.max_assets) and self._admit("GET", dest, "asset"):
426
+ new.append(Task("GET", dest, task.depth, task.url, "asset"))
427
+ for form in page.forms:
428
+ self._handle_form(task, base, form, new)
429
+ if (is_html or is_js) and self.cfg.probe_js:
430
+ self._probe_refs(task, text, new)
431
+ if task.kind == "sitemap" or task.url.endswith("sitemap.xml"):
432
+ for loc in re.findall(r"<loc>\s*([^<\s]+)\s*</loc>", text):
433
+ dest = self._normalise(task.url, loc)
434
+ if dest and self._admit("GET", dest, "sitemap"):
435
+ new.append(Task("GET", dest, task.depth + 1, task.url, "sitemap"))
436
+ return new
437
+
438
+ def _handle_form(self, task: Task, base: str, form: dict, new: list) -> None:
439
+ self.stats["forms_found"] += 1
440
+ action = self._normalise(base, form["action"]) or base
441
+ has_password = any(t == "password" for _, t, _ in form["inputs"])
442
+ rec = {"page": task.url, "action": urllib.parse.urlsplit(action).path or "/",
443
+ "method": form["method"], "fields": [n for n, t, _ in form["inputs"]
444
+ if t not in ("submit", "button", "image", "reset")]}
445
+ if has_password:
446
+ rec["note"] = "password field — never submitted"
447
+ self.forms.append(rec)
448
+ data = urllib.parse.urlencode(self._sample(form["inputs"]))
449
+ if form["method"] == "GET":
450
+ sep = "&" if "?" in action else "?"
451
+ dest = action.split("#")[0] + (sep + data if data else "")
452
+ if not has_password and task.depth < self.cfg.max_depth and self._admit("GET", dest, "form"):
453
+ new.append(Task("GET", dest, task.depth + 1, task.url, "form"))
454
+ elif self.cfg.submit_forms and not has_password and self.in_scope(action) \
455
+ and not _UNSAFE.search(action):
456
+ key = ("POST", action, data)
457
+ if key not in self.seen and self.stats["forms_submitted"] < 25:
458
+ self.seen.add(key)
459
+ self.stats["forms_submitted"] += 1
460
+ new.append(Task("POST", action, task.depth + 1, task.url, "form", data.encode(),
461
+ "application/x-www-form-urlencoded"))
462
+
463
+ def _probe_refs(self, task: Task, text: str, new: list) -> None:
464
+ """Request the concrete GET endpoints a script or page names."""
465
+ try:
466
+ refs = extract_js_refs(text)
467
+ except Exception:
468
+ return
469
+ for method, host, path, _kind in sorted(refs, key=lambda r: (r[2], r[0] or "")):
470
+ if "{" in path or _STATIC_ASSET.search(path):
471
+ continue
472
+ if method not in (None, "GET"):
473
+ self.stats["js_refs_not_requested"] += 1 # named, but not a GET: not sent
474
+ continue
475
+ if host:
476
+ dest = f"{urllib.parse.urlsplit(task.url).scheme}://{host}{path}"
477
+ else:
478
+ u = urllib.parse.urlsplit(self.cfg.start)
479
+ dest = f"{u.scheme}://{u.netloc}{path}"
480
+ if self._admit("GET", dest, "js"):
481
+ new.append(Task("GET", dest, task.depth + 1, self.cfg.start, "js"))
482
+
483
+ # --------------------------------------------------------------- login ---
484
+ def _login(self) -> bool:
485
+ cfg = self.cfg
486
+ url = self._normalise(cfg.start, cfg.auth_login)
487
+ if not url or not self.in_scope(url):
488
+ print(f"crawl: --auth-login {cfg.auth_login!r} is outside the crawl scope", file=sys.stderr)
489
+ return False
490
+ task = Task("POST", url, 0, cfg.start, "seed", (cfg.auth_body or "{}").encode(), "application/json")
491
+ self.seen.add(("POST", urllib.parse.urlsplit(url).scheme, urllib.parse.urlsplit(url).netloc.lower(),
492
+ urllib.parse.urlsplit(url).path, urllib.parse.urlsplit(url).query))
493
+ got = self.fetch(task)
494
+ if got is None:
495
+ print("crawl: login request failed", file=sys.stderr)
496
+ return False
497
+ item, status, low, body = got
498
+ self.items.append(item)
499
+ self.stats["requests"] += 1
500
+ if cfg.auth_token:
501
+ try:
502
+ node = json.loads(body.decode("utf-8", "replace"))
503
+ for part in cfg.auth_token.split("."):
504
+ node = node[int(part)] if isinstance(node, list) else node[part]
505
+ self.headers[cfg.auth_header] = (f"{cfg.auth_scheme} {node}" if cfg.auth_scheme else str(node))
506
+ except (ValueError, KeyError, IndexError, TypeError):
507
+ print(f"crawl: login answered {status} but no token at {cfg.auth_token!r}; "
508
+ "crawling without it", file=sys.stderr)
509
+ return False
510
+ elif not (200 <= status < 300):
511
+ print(f"crawl: login answered {status}; crawling without it", file=sys.stderr)
512
+ return False
513
+ return True
514
+
515
+ # ----------------------------------------------------------------- run ---
516
+ # ----------------------------------------------------------- journeys ---
517
+ def _journey_note(self, j, n: int, kind: str, msg: str) -> None:
518
+ line = f"journey {j.name!r} step {n} ({kind}): {msg}"
519
+ self.journey_log.append(line)
520
+ self.stats["journey_failed"] += 1
521
+ print(f"crawl: {line}", file=sys.stderr)
522
+
523
+ def _run_journeys_static(self) -> None:
524
+ """goto / request / set_header / set_cookie over plain HTTP; the rest need a browser."""
525
+ from . import journey as jy
526
+ origin = f"{self.start.scheme}://{self.start.netloc}"
527
+ for j in self.cfg.journeys:
528
+ variables = {}
529
+ try:
530
+ for k, v in j.vars.items():
531
+ variables[k] = jy.substitute(v, {**variables, **self.cfg.journey_vars})
532
+ variables.update(self.cfg.journey_vars)
533
+ except jy.JourneyError as e:
534
+ self._journey_note(j, 0, "vars", str(e))
535
+ continue
536
+ for n, step in enumerate(j.steps, 1):
537
+ kind = next(k for k in step if k in jy.STEPS)
538
+ self.stats["journey_steps"] += 1
539
+ try:
540
+ if kind in jy.BROWSER_ONLY:
541
+ raise jy.JourneyError("needs a browser (--browser on); the static crawler cannot click or fill")
542
+ arg = jy.substitute(step[kind], variables)
543
+ if kind == "goto":
544
+ url = urllib.parse.urljoin(origin + "/", arg)
545
+ self._journey_fetch("GET", url, None, None, variables, {})
546
+ elif kind == "request":
547
+ self._journey_request(arg, origin, variables)
548
+ elif kind == "set_header":
549
+ self.headers.update({k: str(v) for k, v in arg.items()})
550
+ elif kind == "set_cookie":
551
+ self.jar.setdefault(self.start.hostname.lower(), {}).update(
552
+ {k: str(v) for k, v in arg.items()})
553
+ elif kind in ("wait",):
554
+ time.sleep(float(arg))
555
+ except jy.JourneyError as e:
556
+ self._journey_note(j, n, kind, str(e))
557
+ if step.get("required"):
558
+ break
559
+
560
+ def _journey_fetch(self, method, url, body, ctype, variables, extract, expect=None, headers=None):
561
+ from . import journey as jy
562
+ if not self.in_scope(url):
563
+ raise jy.JourneyError(f"{url} is outside the crawl scope")
564
+ if self.cfg.read_only and method.upper() not in ("GET", "HEAD", "OPTIONS"):
565
+ raise jy.JourneyError(f"{method} blocked by --read-only")
566
+ saved = dict(self.headers)
567
+ self.headers.update(headers or {})
568
+ try:
569
+ got = self.fetch(Task(method.upper(), url, 0, self.cfg.start, "journey", body, ctype))
570
+ finally:
571
+ self.headers.clear()
572
+ self.headers.update(saved)
573
+ if got is None:
574
+ raise jy.JourneyError(f"{method} {url} failed (no response)")
575
+ item, status, low, text = got
576
+ self.items.append(item)
577
+ self.stats["requests"] += 1
578
+ if expect is not None and status not in ([expect] if isinstance(expect, int) else expect):
579
+ raise jy.JourneyError(f"{method} {url} answered {status}, expected {expect}")
580
+ if extract:
581
+ try:
582
+ data = json.loads(text.decode("utf-8", "replace"))
583
+ except ValueError:
584
+ data = None
585
+ for name, path in extract.items():
586
+ val = jy.dig(data, path) if isinstance(path, str) else None
587
+ if val is None:
588
+ raise jy.JourneyError(f"could not extract {name!r} from {path!r} in the response")
589
+ variables[name] = val
590
+
591
+ def _journey_request(self, spec: dict, origin: str, variables: dict) -> None:
592
+ url = spec.get("url") or urllib.parse.urljoin(origin + "/", spec["path"])
593
+ body, ctype = None, None
594
+ if "json" in spec:
595
+ body, ctype = json.dumps(spec["json"]).encode(), "application/json"
596
+ elif "form" in spec:
597
+ body, ctype = urllib.parse.urlencode(spec["form"]).encode(), "application/x-www-form-urlencoded"
598
+ elif "body" in spec:
599
+ body, ctype = str(spec["body"]).encode(), spec.get("content_type")
600
+ self._journey_fetch(spec.get("method", "GET"), url, body, ctype, variables,
601
+ spec.get("extract") or {}, spec.get("expect"), spec.get("headers"))
602
+
603
+ def run(self) -> CrawlResult:
604
+ cfg = self.cfg
605
+ if cfg.auth_login:
606
+ self._login()
607
+ if cfg.journeys:
608
+ self._run_journeys_static()
609
+ if cfg.journey_only:
610
+ return CrawlResult(self.items, self.stats, self.forms, self.scope)
611
+ start_url = urllib.parse.urldefrag(cfg.start)[0]
612
+ origin = f"{self.start.scheme}://{self.start.netloc}"
613
+ frontier = [Task("GET", start_url, 0, None, "seed")]
614
+ self._admit("GET", start_url, "seed")
615
+ for extra, kind in (("/robots.txt", "seed"), ("/sitemap.xml", "sitemap")):
616
+ if self._admit("GET", origin + extra, kind):
617
+ frontier.append(Task("GET", origin + extra, 0, None, kind))
618
+
619
+ with ThreadPoolExecutor(max_workers=max(1, min(cfg.threads, 8))) as pool:
620
+ while frontier and (not cfg.max_requests or self.stats["requests"] < cfg.max_requests):
621
+ room = (cfg.max_requests - self.stats["requests"]) if cfg.max_requests else len(frontier)
622
+ batch, frontier = frontier[:room], frontier[room:]
623
+ nxt: list[Task] = list(frontier)
624
+ for task, got in zip(batch, pool.map(self.fetch, batch)):
625
+ if got is None:
626
+ continue
627
+ item, status, low, body = got
628
+ self.items.append(item)
629
+ self.stats["requests"] += 1
630
+ key = {"asset": "assets", "js": "js_probes"}.get(task.kind, "pages")
631
+ self.stats[key] += 1
632
+ if task.url.endswith("/robots.txt") and 200 <= status < 300:
633
+ self._disallow = [m.strip() for m in re.findall(
634
+ r"(?im)^disallow:\s*(\S*)", body.decode("utf-8", "replace"))]
635
+ for sm in re.findall(r"(?im)^sitemap:\s*(\S+)", body.decode("utf-8", "replace")):
636
+ if self._admit("GET", sm, "sitemap"):
637
+ nxt.append(Task("GET", sm, 0, task.url, "sitemap"))
638
+ nxt.extend(self._discover(task, status, low, body))
639
+ if cfg.progress and self.stats["requests"] % 25 == 0:
640
+ print(f"crawl: {self.stats['requests']} requests, {len(nxt)} queued",
641
+ file=sys.stderr)
642
+ frontier = nxt
643
+ if frontier:
644
+ self.stats["skipped_cap"] += len(frontier)
645
+ return CrawlResult(self.items, self.stats, self.forms, self.scope)
646
+
647
+
648
+ def to_burp_xml(items: list) -> str:
649
+ """Burp 'Save items' XML for the crawl, so it can be opened in Burp too.
650
+
651
+ This holds the raw requests and responses, secrets included, exactly like a
652
+ real Burp export. proxy2model's own outputs never do.
653
+ """
654
+ from xml.etree.ElementTree import tostring
655
+ out = ['<?xml version="1.0"?>', f'<items burpVersion="proxy2model-crawler-{__version__}">']
656
+ for it in items:
657
+ out.append(tostring(it, encoding="unicode"))
658
+ out.append("</items>")
659
+ return "\n".join(out) + "\n"
660
+
661
+
662
+ def make_crawler(cfg: CrawlConfig):
663
+ """The browser crawler when a Chrome is available (and not turned off), else the static one."""
664
+ if cfg.browser != "off":
665
+ from .browser import BrowserCrawler, find_chrome
666
+ chrome = find_chrome(cfg.chrome)
667
+ if chrome:
668
+ return BrowserCrawler(cfg, chrome)
669
+ if cfg.browser == "on":
670
+ raise ValueError("no Chrome/Chromium found for --browser on (install one, or pass --chrome PATH)")
671
+ print("crawl: no Chrome/Chromium found; using the static crawler (no JavaScript). "
672
+ "Install one, or pass --chrome PATH, for a JavaScript-enabled crawl.", file=sys.stderr)
673
+ return Crawler(cfg)