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,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)
|