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,1058 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A JavaScript-enabled crawl: drive a real Chrome/Chromium over the DevTools
|
|
3
|
+
Protocol and record what it does, the way Burp's browser-powered crawler does.
|
|
4
|
+
|
|
5
|
+
There is no dependency to install. The WebSocket client and the DevTools client
|
|
6
|
+
below are a few hundred lines of standard library; the only requirement is a
|
|
7
|
+
Chrome or Chromium on the machine (found automatically, or `--chrome PATH`).
|
|
8
|
+
|
|
9
|
+
Why a browser: a single-page app builds its links and calls its API in
|
|
10
|
+
JavaScript, so a fetch-and-parse crawler sees an empty shell. A browser runs the
|
|
11
|
+
app, so the crawl records the real XHR/fetch traffic, with real bodies, headers
|
|
12
|
+
and cookies, and can click through routes, menus, tabs and forms to reach more.
|
|
13
|
+
|
|
14
|
+
What is recorded: every in-scope request the page makes, with its response, as
|
|
15
|
+
a Burp-style item. It goes through the same parser and redaction as a Burp
|
|
16
|
+
export, in memory; nothing raw is written by the crawl.
|
|
17
|
+
|
|
18
|
+
Guard rails (the browser is told, not trusted):
|
|
19
|
+
* Fetch interception blocks every request outside the scope before it is sent,
|
|
20
|
+
and every URL that looks destructive (logout, delete, ...); with --read-only
|
|
21
|
+
it blocks every non-GET as well;
|
|
22
|
+
* dialogs are dismissed (a confirm() is answered "cancel"), downloads are denied;
|
|
23
|
+
* elements whose label looks destructive are never clicked, and a form with a
|
|
24
|
+
password field is never submitted;
|
|
25
|
+
* a request budget, a click budget per page, and an overall time limit.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import base64
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import select
|
|
35
|
+
import shutil
|
|
36
|
+
import socket
|
|
37
|
+
import struct
|
|
38
|
+
import subprocess
|
|
39
|
+
import sys
|
|
40
|
+
import tempfile
|
|
41
|
+
import time
|
|
42
|
+
import urllib.parse
|
|
43
|
+
from collections import deque
|
|
44
|
+
from xml.etree.ElementTree import Element, SubElement
|
|
45
|
+
|
|
46
|
+
from .crawl import _UNSAFE, CrawlConfig, CrawlResult, Crawler, Task, _burp_mime
|
|
47
|
+
from .redact import route_template, template_path
|
|
48
|
+
|
|
49
|
+
_CANDIDATES = [
|
|
50
|
+
"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome",
|
|
51
|
+
"microsoft-edge", "microsoft-edge-stable", "brave-browser",
|
|
52
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
53
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
54
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
55
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
56
|
+
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
|
57
|
+
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def find_chrome(explicit: str | None = None) -> str | None:
|
|
62
|
+
for c in ([explicit] if explicit else []) + [os.environ.get("PROXY2MODEL_CHROME")] + _CANDIDATES:
|
|
63
|
+
if not c:
|
|
64
|
+
continue
|
|
65
|
+
found = shutil.which(c) or (c if os.path.isfile(c) and os.access(c, os.X_OK) else None)
|
|
66
|
+
if found:
|
|
67
|
+
return found
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ------------------------------------------------------------- WebSocket -----
|
|
72
|
+
|
|
73
|
+
def launch_chrome(chrome: str, tmp: str, extra: list[str] | None = None, headless: bool = True,
|
|
74
|
+
attempts: int = 3):
|
|
75
|
+
"""Start Chrome with DevTools on a free port; return (process, websocket url).
|
|
76
|
+
|
|
77
|
+
A cold Chrome on a busy CI runner sometimes exits or never opens its port, so a failed start is
|
|
78
|
+
retried before giving up."""
|
|
79
|
+
args = [chrome, "--no-first-run", "--no-default-browser-check", "--disable-gpu",
|
|
80
|
+
"--disable-extensions", "--disable-background-networking", "--disable-sync", "--mute-audio",
|
|
81
|
+
"--disable-dev-shm-usage", "--hide-scrollbars", "--remote-debugging-port=0",
|
|
82
|
+
f"--user-data-dir={tmp}", "--disable-features=Translate,MediaRouter", "--disable-component-update"]
|
|
83
|
+
if headless:
|
|
84
|
+
args.insert(1, "--headless=new")
|
|
85
|
+
if (hasattr(os, "geteuid") and os.geteuid() == 0) or os.environ.get("PROXY2MODEL_CHROME_NO_SANDBOX") == "1":
|
|
86
|
+
args.append("--no-sandbox") # containers and CI runners that cannot create a sandbox
|
|
87
|
+
args += extra or []
|
|
88
|
+
args.append("about:blank")
|
|
89
|
+
port_file = os.path.join(tmp, "DevToolsActivePort")
|
|
90
|
+
last = "the browser did not open its DevTools port"
|
|
91
|
+
for _ in range(attempts):
|
|
92
|
+
if os.path.exists(port_file):
|
|
93
|
+
os.remove(port_file)
|
|
94
|
+
proc = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
95
|
+
for _ in range(200):
|
|
96
|
+
if os.path.exists(port_file):
|
|
97
|
+
lines = open(port_file).read().split("\n")
|
|
98
|
+
if len(lines) >= 2 and lines[1]:
|
|
99
|
+
return proc, f"ws://127.0.0.1:{lines[0].strip()}{lines[1].strip()}"
|
|
100
|
+
if proc.poll() is not None:
|
|
101
|
+
last = "the browser exited during start-up"
|
|
102
|
+
break
|
|
103
|
+
time.sleep(0.1)
|
|
104
|
+
if proc.poll() is None:
|
|
105
|
+
proc.kill()
|
|
106
|
+
try:
|
|
107
|
+
proc.wait(5)
|
|
108
|
+
except subprocess.TimeoutExpired:
|
|
109
|
+
pass
|
|
110
|
+
raise RuntimeError(last)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class WebSocket:
|
|
114
|
+
"""A minimal RFC 6455 client: text frames, fragments, ping/pong, close."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, url: str, timeout: float = 15.0):
|
|
117
|
+
u = urllib.parse.urlsplit(url)
|
|
118
|
+
self.sock = socket.create_connection((u.hostname, u.port), timeout=timeout)
|
|
119
|
+
key = base64.b64encode(os.urandom(16)).decode()
|
|
120
|
+
path = (u.path or "/") + ("?" + u.query if u.query else "")
|
|
121
|
+
self.sock.sendall((f"GET {path} HTTP/1.1\r\nHost: {u.hostname}:{u.port}\r\n"
|
|
122
|
+
f"Upgrade: websocket\r\nConnection: Upgrade\r\n"
|
|
123
|
+
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode())
|
|
124
|
+
head = b""
|
|
125
|
+
while b"\r\n\r\n" not in head:
|
|
126
|
+
chunk = self.sock.recv(4096)
|
|
127
|
+
if not chunk:
|
|
128
|
+
raise ConnectionError("DevTools closed the connection during the handshake")
|
|
129
|
+
head += chunk
|
|
130
|
+
head, _, rest = head.partition(b"\r\n\r\n")
|
|
131
|
+
if b" 101 " not in head.split(b"\r\n", 1)[0]:
|
|
132
|
+
raise ConnectionError("DevTools refused the WebSocket upgrade: " + head[:80].decode("latin-1"))
|
|
133
|
+
self.buf = rest
|
|
134
|
+
self.closed = False
|
|
135
|
+
|
|
136
|
+
def send(self, text: str) -> None:
|
|
137
|
+
data = text.encode()
|
|
138
|
+
n = len(data)
|
|
139
|
+
head = bytes([0x81])
|
|
140
|
+
if n < 126:
|
|
141
|
+
head += bytes([0x80 | n])
|
|
142
|
+
elif n < 65536:
|
|
143
|
+
head += bytes([0x80 | 126]) + struct.pack(">H", n)
|
|
144
|
+
else:
|
|
145
|
+
head += bytes([0x80 | 127]) + struct.pack(">Q", n)
|
|
146
|
+
mask = os.urandom(4)
|
|
147
|
+
self.sock.sendall(head + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
|
|
148
|
+
|
|
149
|
+
def _need(self, n: int, deadline: float | None) -> bytes | None:
|
|
150
|
+
while len(self.buf) < n:
|
|
151
|
+
if deadline is not None:
|
|
152
|
+
left = deadline - time.monotonic()
|
|
153
|
+
if left <= 0 or not select.select([self.sock], [], [], left)[0]:
|
|
154
|
+
return None
|
|
155
|
+
chunk = self.sock.recv(1 << 20)
|
|
156
|
+
if not chunk:
|
|
157
|
+
self.closed = True
|
|
158
|
+
raise ConnectionError("DevTools connection closed")
|
|
159
|
+
self.buf += chunk
|
|
160
|
+
out, self.buf = self.buf[:n], self.buf[n:]
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
def recv(self, timeout: float | None) -> str | None:
|
|
164
|
+
"""The next text message, or None if none arrives within `timeout`."""
|
|
165
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
166
|
+
parts: list[bytes] = []
|
|
167
|
+
while True:
|
|
168
|
+
h = self._need(2, deadline)
|
|
169
|
+
if h is None:
|
|
170
|
+
return None
|
|
171
|
+
fin, op, n = h[0] & 0x80, h[0] & 0x0F, h[1] & 0x7F
|
|
172
|
+
if n == 126:
|
|
173
|
+
n = struct.unpack(">H", self._need(2, None))[0]
|
|
174
|
+
elif n == 127:
|
|
175
|
+
n = struct.unpack(">Q", self._need(8, None))[0]
|
|
176
|
+
payload = self._need(n, None) if n else b""
|
|
177
|
+
if op == 8:
|
|
178
|
+
self.closed = True
|
|
179
|
+
raise ConnectionError("DevTools closed the WebSocket")
|
|
180
|
+
if op == 9:
|
|
181
|
+
self.sock.sendall(bytes([0x8A, 0x80]) + os.urandom(4)) # empty masked pong
|
|
182
|
+
continue
|
|
183
|
+
if op == 10:
|
|
184
|
+
continue
|
|
185
|
+
parts.append(payload)
|
|
186
|
+
if fin:
|
|
187
|
+
return b"".join(parts).decode("utf-8", "replace")
|
|
188
|
+
|
|
189
|
+
def close(self) -> None:
|
|
190
|
+
try:
|
|
191
|
+
self.sock.close()
|
|
192
|
+
except OSError:
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class CDP:
|
|
197
|
+
"""Commands and events over one DevTools WebSocket."""
|
|
198
|
+
|
|
199
|
+
def __init__(self, ws: WebSocket):
|
|
200
|
+
self.ws = ws
|
|
201
|
+
self.n = 0
|
|
202
|
+
self.events: deque = deque()
|
|
203
|
+
# events that must be answered at once, even while another command is waiting
|
|
204
|
+
# (a paused request blocks the navigation that is waiting for it)
|
|
205
|
+
self.urgent = None
|
|
206
|
+
self.answers: dict = {}
|
|
207
|
+
|
|
208
|
+
def call(self, method: str, params: dict | None = None, session: str | None = None,
|
|
209
|
+
timeout: float = 20.0):
|
|
210
|
+
self.n += 1
|
|
211
|
+
my_id = self.n # urgent events answered while waiting issue commands of their own
|
|
212
|
+
msg = {"id": my_id, "method": method, "params": params or {}}
|
|
213
|
+
if session:
|
|
214
|
+
msg["sessionId"] = session
|
|
215
|
+
self.ws.send(json.dumps(msg))
|
|
216
|
+
end = time.monotonic() + timeout
|
|
217
|
+
while True:
|
|
218
|
+
m = self.answers.pop(my_id, None)
|
|
219
|
+
if m is None:
|
|
220
|
+
raw = self.ws.recv(max(0.05, end - time.monotonic()))
|
|
221
|
+
if raw is None:
|
|
222
|
+
raise TimeoutError(f"DevTools did not answer {method}")
|
|
223
|
+
m = json.loads(raw)
|
|
224
|
+
if "id" in m and m["id"] != my_id:
|
|
225
|
+
# the answer to a command waiting further up the stack (an urgent event
|
|
226
|
+
# handler issued its own command meanwhile): keep it for its owner
|
|
227
|
+
self.answers[m["id"]] = m
|
|
228
|
+
continue
|
|
229
|
+
if m.get("id") == my_id:
|
|
230
|
+
if "error" in m:
|
|
231
|
+
raise RuntimeError(f"{method}: {m['error'].get('message')}")
|
|
232
|
+
return m.get("result", {})
|
|
233
|
+
if "method" in m:
|
|
234
|
+
if self.urgent and m["method"] in ("Fetch.requestPaused", "Page.javascriptDialogOpening"):
|
|
235
|
+
self.urgent(m)
|
|
236
|
+
else:
|
|
237
|
+
self.events.append(m)
|
|
238
|
+
|
|
239
|
+
def next_event(self, timeout: float):
|
|
240
|
+
if self.events:
|
|
241
|
+
return self.events.popleft()
|
|
242
|
+
raw = self.ws.recv(timeout)
|
|
243
|
+
if raw is None:
|
|
244
|
+
return None
|
|
245
|
+
m = json.loads(raw)
|
|
246
|
+
return m if "method" in m else self.next_event(0)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ---------------------------------------------------- in-page helper scripts --
|
|
250
|
+
|
|
251
|
+
_SAFE_JS = r"""
|
|
252
|
+
(() => {
|
|
253
|
+
const bad = /(log-?\s?out|sign-?\s?out|log-?\s?off|delete|remove|destroy|unsubscribe|deactivate|erase|erasure|reset[ /-]*(database|db)|create[ /]+reset|shutdown|purge|wipe|truncate|factory reset|set-?up|install|clear (all|cart|history))/i;
|
|
254
|
+
const vis = e => { const r = e.getBoundingClientRect(); const s = getComputedStyle(e);
|
|
255
|
+
return r.width > 1 && r.height > 1 && s.visibility !== 'hidden' && s.display !== 'none'; };
|
|
256
|
+
const label = e => ((e.innerText || e.value || e.getAttribute('aria-label') || e.title || '') + '').trim().replace(/\s+/g,' ').slice(0, 60);
|
|
257
|
+
window.__b2m = { bad, vis, label };
|
|
258
|
+
return true;
|
|
259
|
+
})()
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
_HARVEST_JS = r"""
|
|
263
|
+
(() => {
|
|
264
|
+
const abs = x => { try { return new URL(x, document.baseURI).href; } catch (e) { return null; } };
|
|
265
|
+
const links = [];
|
|
266
|
+
document.querySelectorAll('a[href],area[href],iframe[src],frame[src]').forEach(e => {
|
|
267
|
+
const v = abs(e.getAttribute('href') || e.getAttribute('src')); if (v) links.push(v); });
|
|
268
|
+
document.querySelectorAll('[routerlink],[ng-reflect-router-link],[data-href],[to]').forEach(e => {
|
|
269
|
+
const v = e.getAttribute('routerlink') || e.getAttribute('ng-reflect-router-link') || e.getAttribute('data-href');
|
|
270
|
+
if (v && v.startsWith('/')) links.push({route: v}); });
|
|
271
|
+
const forms = Array.from(document.forms).map((f, i) => ({
|
|
272
|
+
index: i, action: abs(f.getAttribute('action') || '') || location.href, method: (f.method || 'get').toUpperCase(),
|
|
273
|
+
password: !!f.querySelector('input[type=password]'),
|
|
274
|
+
fields: Array.from(f.elements).filter(x => x.name).map(x => x.name) }));
|
|
275
|
+
return { href: location.href, title: document.title, links, forms,
|
|
276
|
+
hashRouting: location.hash.startsWith('#/') || links.some(l => typeof l === 'string' && l.includes('#/')) };
|
|
277
|
+
})()
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
_CLICKABLES_JS = r"""
|
|
281
|
+
(() => {
|
|
282
|
+
if (!window.__b2m) return [];
|
|
283
|
+
const { bad, vis, label } = window.__b2m;
|
|
284
|
+
const sel = 'button,[role=button],[role=menuitem],[role=tab],[role=link],[role=option],[routerlink],[onclick],summary,' +
|
|
285
|
+
'a:not([href]),a[href="#"],a[href^="javascript:"],[class*=menu-item],[class*=nav-item],[class*=tab],mat-select,[aria-haspopup],[data-toggle],[data-bs-toggle]';
|
|
286
|
+
const out = [];
|
|
287
|
+
window.__b2m_c = Array.from(document.querySelectorAll(sel)).filter(vis);
|
|
288
|
+
window.__b2m_c.forEach((e, i) => {
|
|
289
|
+
const t = label(e);
|
|
290
|
+
if (bad.test(t) || bad.test(e.className || '') || bad.test(e.id || '')) return;
|
|
291
|
+
if (e.closest('form') && e.closest('form').querySelector('input[type=password]')) return;
|
|
292
|
+
if (e.disabled || e.getAttribute('aria-disabled') === 'true') return;
|
|
293
|
+
const r = e.getBoundingClientRect();
|
|
294
|
+
out.push({ i, sig: e.tagName + '|' + t + '|' + (e.getAttribute('routerlink') || e.getAttribute('href') || '') +
|
|
295
|
+
'|' + Math.round(r.x / 40) + ',' + Math.round(r.y / 40) });
|
|
296
|
+
});
|
|
297
|
+
return out;
|
|
298
|
+
})()
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _click_js(i: int) -> str:
|
|
303
|
+
return ("(() => { const e = (window.__b2m_c || [])[%d]; if (!e) return false; "
|
|
304
|
+
"try { e.scrollIntoView({block:'center'}); e.click(); return true; } catch (x) { return false; } })()" % i)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
_FILL_JS = r"""
|
|
308
|
+
(() => {
|
|
309
|
+
const filled = [];
|
|
310
|
+
const val = t => ({email:'test@example.invalid', number:'1', tel:'5550100', url:'https://example.invalid/',
|
|
311
|
+
date:'2030-01-01', search:'a', text:'test'})[t] || 'test';
|
|
312
|
+
const set = (e, v) => { const d = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(e), 'value');
|
|
313
|
+
d && d.set ? d.set.call(e, v) : (e.value = v);
|
|
314
|
+
e.dispatchEvent(new Event('input', {bubbles:true})); e.dispatchEvent(new Event('change', {bubbles:true})); };
|
|
315
|
+
document.querySelectorAll('input:not([type=hidden]):not([type=password]):not([type=file]):not([type=checkbox]):not([type=radio]):not([type=submit]):not([type=button]),textarea').forEach(e => {
|
|
316
|
+
if (e.value || e.disabled || e.readOnly) return;
|
|
317
|
+
const r = e.getBoundingClientRect(); if (r.width < 2 || r.height < 2) return;
|
|
318
|
+
if (e.closest('form') && e.closest('form').querySelector('input[type=password]')) return;
|
|
319
|
+
set(e, val((e.type || 'text').toLowerCase())); filled.push(e.name || e.id || e.type); });
|
|
320
|
+
return filled;
|
|
321
|
+
})()
|
|
322
|
+
"""
|
|
323
|
+
|
|
324
|
+
_SUBMIT_JS = r"""
|
|
325
|
+
(() => {
|
|
326
|
+
const forms = Array.from(document.forms).filter(f => !f.querySelector('input[type=password]'));
|
|
327
|
+
const bad = window.__b2m ? window.__b2m.bad : /$^/;
|
|
328
|
+
let n = 0;
|
|
329
|
+
forms.forEach(f => {
|
|
330
|
+
const txt = (f.innerText || '') + ' ' + (f.action || '');
|
|
331
|
+
if (bad.test(txt)) return;
|
|
332
|
+
try { f.requestSubmit ? f.requestSubmit() : f.submit(); n++; } catch (e) {}
|
|
333
|
+
});
|
|
334
|
+
return n;
|
|
335
|
+
})()
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
_ENTER_JS = r"""
|
|
339
|
+
(() => { const e = document.querySelector('input[type=search],input[type=text]:not([form])');
|
|
340
|
+
if (!e || !e.value) return false;
|
|
341
|
+
['keydown','keypress','keyup'].forEach(t => e.dispatchEvent(new KeyboardEvent(t, {key:'Enter', code:'Enter', keyCode:13, which:13, bubbles:true})));
|
|
342
|
+
return true; })()
|
|
343
|
+
"""
|
|
344
|
+
|
|
345
|
+
_SCROLL_JS = "(() => { window.scrollTo(0, document.body.scrollHeight); return document.body.scrollHeight; })()"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# chatty background channels (long-polling, event streams, dev servers) that never "finish"
|
|
349
|
+
_BACKGROUND = re.compile(r"socket\.io|sockjs|transport=(?:polling|websocket)|/signalr|/hub(?:/|\?|$)|"
|
|
350
|
+
r"__webpack_hmr|livereload|/sse\b|/events?(?:\?|$)", re.I)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class _Rec:
|
|
354
|
+
"""One request as the browser made it, assembled from several DevTools events."""
|
|
355
|
+
__slots__ = ("id", "req", "extra_req", "resp", "extra_resp", "body", "b64", "rtype", "done", "failed",
|
|
356
|
+
"post", "redirect")
|
|
357
|
+
|
|
358
|
+
def __init__(self, rid):
|
|
359
|
+
self.id, self.req, self.extra_req, self.resp, self.extra_resp = rid, None, None, None, None
|
|
360
|
+
self.body, self.b64, self.rtype, self.done, self.failed, self.post, self.redirect = b"", False, "", False, False, None, False
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def rec_to_item(rec: _Rec, in_scope) -> Element | None:
|
|
364
|
+
"""One recorded request and its response as a Burp-style <item>, or None when out of scope."""
|
|
365
|
+
req, resp = rec.req, rec.resp
|
|
366
|
+
if not req or not resp:
|
|
367
|
+
return None
|
|
368
|
+
u = urllib.parse.urlsplit(req["url"])
|
|
369
|
+
if u.scheme not in ("http", "https") or not in_scope(req["url"]):
|
|
370
|
+
return None
|
|
371
|
+
port = u.port or (443 if u.scheme == "https" else 80)
|
|
372
|
+
path = (u.path or "/") + ("?" + u.query if u.query else "")
|
|
373
|
+
rq = dict(req.get("headers") or {})
|
|
374
|
+
rq.update(rec.extra_req or {})
|
|
375
|
+
rq = {k: v for k, v in rq.items() if not k.startswith(":")}
|
|
376
|
+
if not any(k.lower() == "host" for k in rq):
|
|
377
|
+
rq = {"Host": u.netloc, **rq}
|
|
378
|
+
post = (rec.post or "").encode("utf-8", "replace") if rec.post else b""
|
|
379
|
+
if post and not any(k.lower() == "content-length" for k in rq):
|
|
380
|
+
rq["Content-Length"] = str(len(post))
|
|
381
|
+
raw_req = (f"{req['method']} {path} HTTP/1.1\r\n" + "".join(f"{k}: {v}\r\n" for k, v in rq.items())
|
|
382
|
+
+ "\r\n").encode("latin-1", "replace") + post
|
|
383
|
+
status = int(resp.get("status") or 0)
|
|
384
|
+
rh: list[tuple[str, str]] = []
|
|
385
|
+
for k, v in {**(resp.get("headers") or {}), **(rec.extra_resp or {})}.items():
|
|
386
|
+
if k.startswith(":") or k.lower() in ("content-encoding", "transfer-encoding", "content-length"):
|
|
387
|
+
continue
|
|
388
|
+
for line in str(v).split("\n"):
|
|
389
|
+
rh.append((k, line))
|
|
390
|
+
# DevTools hands back the body already decoded, so the encoding header is dropped
|
|
391
|
+
rh.append(("Content-Length", str(len(rec.body))))
|
|
392
|
+
head = f"HTTP/1.1 {status} {resp.get('statusText') or ''}\r\n" + "".join(f"{k}: {v}\r\n" for k, v in rh) + "\r\n"
|
|
393
|
+
raw_resp = head.encode("latin-1", "replace") + rec.body
|
|
394
|
+
item = Element("item")
|
|
395
|
+
low = {k.lower(): v for k, v in (resp.get("headers") or {}).items()}
|
|
396
|
+
for tag, val in (("method", req["method"]), ("host", u.hostname.lower()), ("port", str(port)),
|
|
397
|
+
("protocol", u.scheme), ("path", path), ("status", str(status)),
|
|
398
|
+
("mimetype", _burp_mime(low.get("content-type", resp.get("mimeType", ""))))):
|
|
399
|
+
SubElement(item, tag).text = val
|
|
400
|
+
SubElement(item, "request", {"base64": "true"}).text = base64.b64encode(raw_req).decode()
|
|
401
|
+
SubElement(item, "response", {"base64": "true"}).text = base64.b64encode(raw_resp).decode()
|
|
402
|
+
return item
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class BrowserCrawler(Crawler):
|
|
406
|
+
"""Crawl with a real browser; fall back to nothing silently (see make_crawler)."""
|
|
407
|
+
|
|
408
|
+
def __init__(self, cfg: CrawlConfig, chrome: str, attach: str | None = None):
|
|
409
|
+
super().__init__(cfg)
|
|
410
|
+
self.chrome = chrome
|
|
411
|
+
# `attach`: the DevTools URL of a browser someone is using (`browse --active`). The crawl
|
|
412
|
+
# then runs in a background tab of that browser, sharing its login, and leaves it open.
|
|
413
|
+
self.attach = attach
|
|
414
|
+
self.target: str | None = None
|
|
415
|
+
self.proc = None
|
|
416
|
+
self.tmp = None
|
|
417
|
+
self.cdp: CDP | None = None
|
|
418
|
+
self.session: str | None = None
|
|
419
|
+
self.recs: dict[str, _Rec] = {}
|
|
420
|
+
self.inflight: set[str] = set()
|
|
421
|
+
self.started: dict[str, float] = {}
|
|
422
|
+
self.last_net = time.monotonic()
|
|
423
|
+
self.finalised: list[_Rec] = []
|
|
424
|
+
self._heavy_assets = 0
|
|
425
|
+
self.stats.update({"browser": True, "clicks": 0, "forms_submitted": 0, "states": 0,
|
|
426
|
+
"blocked_scope": 0, "blocked_unsafe": 0, "blocked_method": 0, "dialogs_dismissed": 0,
|
|
427
|
+
"blocked_asset_cap": 0})
|
|
428
|
+
self._deadline = time.monotonic() + (cfg.max_seconds if cfg.max_seconds else 10 ** 9)
|
|
429
|
+
self._journey_active = False # a scripted step is deliberate: no "looks destructive" filter
|
|
430
|
+
self.screenshot: bytes | None = None
|
|
431
|
+
self._scripts: list[tuple[str, str]] = [] # (url, text) of JS/HTML bodies, for the static pass
|
|
432
|
+
|
|
433
|
+
# ------------------------------------------------------------ lifecycle --
|
|
434
|
+
def _launch(self) -> None:
|
|
435
|
+
if self.attach:
|
|
436
|
+
self.cdp = CDP(WebSocket(self.attach))
|
|
437
|
+
self.cdp.urgent = self._handle
|
|
438
|
+
return
|
|
439
|
+
self.tmp = tempfile.mkdtemp(prefix="b2m-chrome-")
|
|
440
|
+
extra = ["--window-size=1366,900", "--disable-popup-blocking"]
|
|
441
|
+
if self.cfg.insecure:
|
|
442
|
+
extra.append("--ignore-certificate-errors")
|
|
443
|
+
self.proc, url = launch_chrome(self.chrome, self.tmp, extra, headless=not self.cfg.headful)
|
|
444
|
+
self.cdp = CDP(WebSocket(url))
|
|
445
|
+
self.cdp.urgent = self._handle
|
|
446
|
+
|
|
447
|
+
def _capture(self) -> bytes | None:
|
|
448
|
+
"""The page as it looks now: a JPEG of the viewport, small enough to embed."""
|
|
449
|
+
for quality in (72, 50):
|
|
450
|
+
try:
|
|
451
|
+
r = self._page("Page.captureScreenshot", {"format": "jpeg", "quality": quality}, 15)
|
|
452
|
+
data = base64.b64decode(r.get("data") or "")
|
|
453
|
+
except (RuntimeError, TimeoutError, ValueError):
|
|
454
|
+
return None
|
|
455
|
+
if data and len(data) <= 600_000:
|
|
456
|
+
return data
|
|
457
|
+
return None
|
|
458
|
+
|
|
459
|
+
def _close(self) -> None:
|
|
460
|
+
try:
|
|
461
|
+
if self.cdp and self.attach and self.target:
|
|
462
|
+
self.cdp.call("Target.closeTarget", {"targetId": self.target}, timeout=5)
|
|
463
|
+
except Exception:
|
|
464
|
+
pass
|
|
465
|
+
try:
|
|
466
|
+
if self.cdp:
|
|
467
|
+
self.cdp.ws.close()
|
|
468
|
+
except Exception:
|
|
469
|
+
pass
|
|
470
|
+
if self.proc and self.proc.poll() is None:
|
|
471
|
+
self.proc.terminate()
|
|
472
|
+
try:
|
|
473
|
+
self.proc.wait(5)
|
|
474
|
+
except subprocess.TimeoutExpired:
|
|
475
|
+
self.proc.kill()
|
|
476
|
+
if self.tmp:
|
|
477
|
+
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
478
|
+
|
|
479
|
+
def _page(self, method: str, params: dict | None = None, timeout: float = 20.0):
|
|
480
|
+
return self.cdp.call(method, params, self.session, timeout)
|
|
481
|
+
|
|
482
|
+
def _eval(self, expr: str, timeout: float = 15.0):
|
|
483
|
+
try:
|
|
484
|
+
r = self._page("Runtime.evaluate", {"expression": expr, "returnByValue": True,
|
|
485
|
+
"awaitPromise": True}, timeout)
|
|
486
|
+
except (RuntimeError, TimeoutError):
|
|
487
|
+
return None
|
|
488
|
+
return (r.get("result") or {}).get("value")
|
|
489
|
+
|
|
490
|
+
def _setup(self) -> None:
|
|
491
|
+
c = self.cdp
|
|
492
|
+
if not self.attach: # never change the downloads of someone's own browser
|
|
493
|
+
try:
|
|
494
|
+
c.call("Browser.setDownloadBehavior", {"behavior": "deny"})
|
|
495
|
+
except RuntimeError:
|
|
496
|
+
pass
|
|
497
|
+
tid = self.target = c.call("Target.createTarget", {"url": "about:blank", "background": True})["targetId"]
|
|
498
|
+
self.session = c.call("Target.attachToTarget", {"targetId": tid, "flatten": True})["sessionId"]
|
|
499
|
+
for dom in ("Page", "Network", "Runtime"):
|
|
500
|
+
self._page(f"{dom}.enable")
|
|
501
|
+
self._page("Network.setCacheDisabled", {"cacheDisabled": True})
|
|
502
|
+
self._page("Fetch.enable", {"patterns": [{"urlPattern": "*"}]})
|
|
503
|
+
if self.headers:
|
|
504
|
+
self._page("Network.setExtraHTTPHeaders", {"headers": self.headers})
|
|
505
|
+
origin = f"{self.start.scheme}://{self.start.netloc}"
|
|
506
|
+
for k, v in (self.cfg.cookies or {}).items():
|
|
507
|
+
self._page("Network.setCookie", {"name": k, "value": v, "url": origin})
|
|
508
|
+
inject = {}
|
|
509
|
+
inject.update(self.cfg.local_storage or {})
|
|
510
|
+
if self.cfg.auth_storage and self.headers.get(self.cfg.auth_header):
|
|
511
|
+
tok = self.headers[self.cfg.auth_header].split(" ", 1)[-1]
|
|
512
|
+
inject[self.cfg.auth_storage] = tok
|
|
513
|
+
self._page("Network.setCookie", {"name": self.cfg.auth_storage, "value": tok, "url": origin})
|
|
514
|
+
if inject:
|
|
515
|
+
js = "try{" + ";".join(f"localStorage.setItem({json.dumps(k)},{json.dumps(v)})"
|
|
516
|
+
for k, v in inject.items()) + "}catch(e){}"
|
|
517
|
+
self._page("Page.addScriptToEvaluateOnNewDocument", {"source": js})
|
|
518
|
+
|
|
519
|
+
# --------------------------------------------------------------- events --
|
|
520
|
+
# Image/Media/Font never carry an endpoint or app logic, so once the budget for them
|
|
521
|
+
# is spent the browser is left to render without them rather than starving the page
|
|
522
|
+
# and API requests that the model is actually built from.
|
|
523
|
+
_HEAVY_ASSET_TYPES = frozenset({"Image", "Media", "Font"})
|
|
524
|
+
|
|
525
|
+
def _allow(self, url: str, method: str, resource_type: str = "") -> bool:
|
|
526
|
+
if not self.in_scope(url):
|
|
527
|
+
self.stats["blocked_scope"] += 1
|
|
528
|
+
return False
|
|
529
|
+
u = urllib.parse.urlsplit(url)
|
|
530
|
+
if not self._journey_active and (_UNSAFE.search(u.path) or _UNSAFE.search(u.query)):
|
|
531
|
+
self.stats["blocked_unsafe"] += 1
|
|
532
|
+
return False
|
|
533
|
+
if self.cfg.read_only and method.upper() not in ("GET", "HEAD", "OPTIONS"):
|
|
534
|
+
self.stats["blocked_method"] += 1
|
|
535
|
+
return False
|
|
536
|
+
if resource_type in self._HEAVY_ASSET_TYPES and self.cfg.max_assets:
|
|
537
|
+
if self._heavy_assets >= self.cfg.max_assets:
|
|
538
|
+
self.stats["blocked_asset_cap"] += 1
|
|
539
|
+
return False
|
|
540
|
+
self._heavy_assets += 1
|
|
541
|
+
return True
|
|
542
|
+
|
|
543
|
+
def _handle(self, ev: dict) -> None:
|
|
544
|
+
m, p = ev["method"], ev.get("params", {})
|
|
545
|
+
sid = ev.get("sessionId")
|
|
546
|
+
if sid and sid != self.session:
|
|
547
|
+
return
|
|
548
|
+
if m == "Fetch.requestPaused":
|
|
549
|
+
req = p["request"]
|
|
550
|
+
try:
|
|
551
|
+
if self._allow(req["url"], req["method"], p.get("resourceType", "")):
|
|
552
|
+
self._page("Fetch.continueRequest", {"requestId": p["requestId"]}, 5)
|
|
553
|
+
else:
|
|
554
|
+
self._page("Fetch.failRequest", {"requestId": p["requestId"],
|
|
555
|
+
"errorReason": "BlockedByClient"}, 5)
|
|
556
|
+
except (RuntimeError, TimeoutError):
|
|
557
|
+
pass
|
|
558
|
+
elif m == "Page.javascriptDialogOpening":
|
|
559
|
+
self.stats["dialogs_dismissed"] += 1
|
|
560
|
+
try:
|
|
561
|
+
self._page("Page.handleJavaScriptDialog",
|
|
562
|
+
{"accept": p.get("type") == "alert"}, 5)
|
|
563
|
+
except (RuntimeError, TimeoutError):
|
|
564
|
+
pass
|
|
565
|
+
elif m == "Network.requestWillBeSent":
|
|
566
|
+
rid = p["requestId"]
|
|
567
|
+
if p.get("redirectResponse") and rid in self.recs:
|
|
568
|
+
old = self.recs[rid]
|
|
569
|
+
old.resp, old.done, old.redirect = p["redirectResponse"], True, True
|
|
570
|
+
self.finalised.append(old)
|
|
571
|
+
self.inflight.discard(rid)
|
|
572
|
+
rec = _Rec(rid)
|
|
573
|
+
rec.req, rec.rtype = p["request"], p.get("type", "")
|
|
574
|
+
rec.post = rec.req.get("postData")
|
|
575
|
+
self.recs[rid] = rec
|
|
576
|
+
if not _BACKGROUND.search(rec.req.get("url", "")):
|
|
577
|
+
self.inflight.add(rid)
|
|
578
|
+
self.started[rid] = time.monotonic()
|
|
579
|
+
self.last_net = time.monotonic()
|
|
580
|
+
elif m == "Network.requestWillBeSentExtraInfo":
|
|
581
|
+
if p["requestId"] in self.recs:
|
|
582
|
+
self.recs[p["requestId"]].extra_req = p.get("headers")
|
|
583
|
+
elif m == "Network.responseReceived":
|
|
584
|
+
if p["requestId"] in self.recs:
|
|
585
|
+
self.recs[p["requestId"]].resp = p["response"]
|
|
586
|
+
elif m == "Network.responseReceivedExtraInfo":
|
|
587
|
+
if p["requestId"] in self.recs:
|
|
588
|
+
self.recs[p["requestId"]].extra_resp = p.get("headers")
|
|
589
|
+
elif m == "Network.loadingFinished":
|
|
590
|
+
rid = p["requestId"]
|
|
591
|
+
rec = self.recs.get(rid)
|
|
592
|
+
self.inflight.discard(rid)
|
|
593
|
+
if rec and not _BACKGROUND.search((rec.req or {}).get("url", "")):
|
|
594
|
+
self.last_net = time.monotonic()
|
|
595
|
+
if rec and rec.resp:
|
|
596
|
+
self._fetch_body(rec)
|
|
597
|
+
rec.done = True
|
|
598
|
+
self.finalised.append(rec)
|
|
599
|
+
elif m == "Network.loadingFailed":
|
|
600
|
+
self.inflight.discard(p["requestId"])
|
|
601
|
+
self.last_net = time.monotonic()
|
|
602
|
+
rec = self.recs.get(p["requestId"])
|
|
603
|
+
if rec:
|
|
604
|
+
rec.failed = True
|
|
605
|
+
|
|
606
|
+
def _fetch_body(self, rec: _Rec) -> None:
|
|
607
|
+
if rec.rtype in ("Image", "Font", "Media") or not rec.resp:
|
|
608
|
+
return
|
|
609
|
+
try:
|
|
610
|
+
r = self._page("Network.getResponseBody", {"requestId": rec.id}, 15)
|
|
611
|
+
body = r.get("body", "")
|
|
612
|
+
rec.body = base64.b64decode(body) if r.get("base64Encoded") else body.encode("utf-8", "replace")
|
|
613
|
+
rec.body = rec.body[: self.cfg.max_body]
|
|
614
|
+
except (RuntimeError, TimeoutError, ValueError):
|
|
615
|
+
pass
|
|
616
|
+
if rec.req and rec.req.get("hasPostData") and rec.post is None:
|
|
617
|
+
try:
|
|
618
|
+
rec.post = self._page("Network.getRequestPostData", {"requestId": rec.id}, 5).get("postData")
|
|
619
|
+
except (RuntimeError, TimeoutError):
|
|
620
|
+
pass
|
|
621
|
+
|
|
622
|
+
def _pump(self, seconds: float) -> None:
|
|
623
|
+
end = time.monotonic() + seconds
|
|
624
|
+
while time.monotonic() < end:
|
|
625
|
+
ev = self.cdp.next_event(max(0.01, end - time.monotonic()))
|
|
626
|
+
if ev is None:
|
|
627
|
+
return
|
|
628
|
+
self._handle(ev)
|
|
629
|
+
|
|
630
|
+
def _settle(self, quiet: float = 0.6, limit: float = 12.0) -> None:
|
|
631
|
+
"""Wait for the network to go quiet (nothing in flight for `quiet` seconds)."""
|
|
632
|
+
end = time.monotonic() + limit
|
|
633
|
+
self.last_net = time.monotonic()
|
|
634
|
+
while time.monotonic() < min(end, self._deadline):
|
|
635
|
+
self._pump(0.1)
|
|
636
|
+
now = time.monotonic()
|
|
637
|
+
# a request still open after 2.5 s is a long-poll or an event stream
|
|
638
|
+
# (socket.io, SSE); it must not keep the page "busy" forever
|
|
639
|
+
young = [r for r in self.inflight if now - self.started.get(r, now) < 2.5]
|
|
640
|
+
if not young and now - self.last_net >= quiet:
|
|
641
|
+
return
|
|
642
|
+
|
|
643
|
+
# ------------------------------------------------------------- recording --
|
|
644
|
+
def _to_item(self, rec: _Rec) -> Element | None:
|
|
645
|
+
item = rec_to_item(rec, self.in_scope)
|
|
646
|
+
if item is None:
|
|
647
|
+
return None
|
|
648
|
+
low = {k.lower(): v for k, v in (rec.resp.get("headers") or {}).items()}
|
|
649
|
+
ct = low.get("content-type", rec.resp.get("mimeType", "")).lower()
|
|
650
|
+
if rec.body and ("javascript" in ct or "html" in ct) and len(self._scripts) < 400:
|
|
651
|
+
self._scripts.append((rec.req["url"], rec.body.decode("utf-8", "replace")))
|
|
652
|
+
return item
|
|
653
|
+
|
|
654
|
+
def _drain(self) -> None:
|
|
655
|
+
while self.finalised:
|
|
656
|
+
rec = self.finalised.pop(0)
|
|
657
|
+
item = self._to_item(rec)
|
|
658
|
+
if item is None:
|
|
659
|
+
continue
|
|
660
|
+
self.items.append(item)
|
|
661
|
+
self.stats["requests"] += 1
|
|
662
|
+
if rec.rtype in ("Document",):
|
|
663
|
+
self.stats["pages"] += 1
|
|
664
|
+
elif rec.rtype in ("XHR", "Fetch"):
|
|
665
|
+
self.stats["js_probes"] += 1
|
|
666
|
+
else:
|
|
667
|
+
self.stats["assets"] += 1
|
|
668
|
+
|
|
669
|
+
def _budget_left(self) -> bool:
|
|
670
|
+
if time.monotonic() > self._deadline:
|
|
671
|
+
return False
|
|
672
|
+
return not self.cfg.max_requests or self.stats["requests"] < self.cfg.max_requests
|
|
673
|
+
|
|
674
|
+
# -------------------------------------------------------------- crawling --
|
|
675
|
+
def _state_url(self) -> str:
|
|
676
|
+
return self._eval("location.href") or ""
|
|
677
|
+
|
|
678
|
+
def _navigate(self, url: str) -> bool:
|
|
679
|
+
try:
|
|
680
|
+
self._page("Page.navigate", {"url": url}, 25)
|
|
681
|
+
except (RuntimeError, TimeoutError):
|
|
682
|
+
return False
|
|
683
|
+
self._pump(0.2)
|
|
684
|
+
self._settle()
|
|
685
|
+
self._drain()
|
|
686
|
+
self._eval(_SAFE_JS)
|
|
687
|
+
return True
|
|
688
|
+
|
|
689
|
+
@staticmethod
|
|
690
|
+
def _key(url: str) -> str:
|
|
691
|
+
"""Canonical state key: the fragment only counts for hash routes (#/ and #!)."""
|
|
692
|
+
base, _, frag = url.partition("#")
|
|
693
|
+
return base + ("#" + frag if frag.startswith(("/", "!")) else "")
|
|
694
|
+
|
|
695
|
+
def _enqueue(self, url: str, depth: int, referer: str, queue: deque, kind="page") -> None:
|
|
696
|
+
url = url.split("#")[0] + ("#" + url.partition("#")[2] if url.partition("#")[2].startswith(("/", "!")) else "")
|
|
697
|
+
if not url.startswith(("http://", "https://")) or not self.in_scope(url):
|
|
698
|
+
return
|
|
699
|
+
if _UNSAFE.search(urllib.parse.urlsplit(url).path):
|
|
700
|
+
return
|
|
701
|
+
key = self._key(url)
|
|
702
|
+
if ("GET", key) in self.seen:
|
|
703
|
+
return
|
|
704
|
+
self.seen.add(("GET", key))
|
|
705
|
+
frag = url.partition("#")[2]
|
|
706
|
+
us = urllib.parse.urlsplit(url)
|
|
707
|
+
t = (us.netloc, route_template(us.path + ("?" + us.query if us.query else ""), template_path(us.path)),
|
|
708
|
+
template_path("/" + frag.lstrip("/!")) if frag.startswith(("/", "!")) else "")
|
|
709
|
+
if self.template_count.get(t, 0) >= self.cfg.per_template_cap:
|
|
710
|
+
self.stats["skipped_cap"] += 1
|
|
711
|
+
return
|
|
712
|
+
self.template_count[t] = self.template_count.get(t, 0) + 1
|
|
713
|
+
queue.append((url, depth))
|
|
714
|
+
|
|
715
|
+
def _harvest(self, depth: int, queue: deque, page_url: str) -> dict:
|
|
716
|
+
h = self._eval(_HARVEST_JS) or {}
|
|
717
|
+
base = h.get("href", page_url)
|
|
718
|
+
for link in h.get("links", []):
|
|
719
|
+
if isinstance(link, dict): # [routerLink] -> a route on this app
|
|
720
|
+
route = link["route"]
|
|
721
|
+
origin = f"{self.start.scheme}://{self.start.netloc}"
|
|
722
|
+
dest = f"{origin}/#{route}" if h.get("hashRouting") else origin + route
|
|
723
|
+
self._enqueue(dest, depth + 1, base, queue)
|
|
724
|
+
elif isinstance(link, str):
|
|
725
|
+
self._enqueue(link, depth + 1, base, queue)
|
|
726
|
+
for f in h.get("forms", []):
|
|
727
|
+
self.stats["forms_found"] += 1
|
|
728
|
+
self.forms.append({"page": base, "action": urllib.parse.urlsplit(f["action"]).path or "/",
|
|
729
|
+
"method": f["method"], "fields": f["fields"],
|
|
730
|
+
**({"note": "password field — never submitted"} if f["password"] else {})})
|
|
731
|
+
return h
|
|
732
|
+
|
|
733
|
+
def _interact(self, url: str, depth: int, queue: deque) -> None:
|
|
734
|
+
"""Click what a user would, fill and submit forms, scroll; follow what changes."""
|
|
735
|
+
cfg = self.cfg
|
|
736
|
+
if cfg.no_interact:
|
|
737
|
+
return
|
|
738
|
+
clicked: set = set()
|
|
739
|
+
clicks = 0
|
|
740
|
+
# fill empty fields first, so a click on a form's own submit button sends real values
|
|
741
|
+
filled = self._eval(_FILL_JS) or []
|
|
742
|
+
self._eval(_SCROLL_JS)
|
|
743
|
+
self._settle(0.4, 5)
|
|
744
|
+
while clicks < cfg.max_clicks and self._budget_left():
|
|
745
|
+
cands = self._eval(_CLICKABLES_JS) or []
|
|
746
|
+
nxt = next((c for c in cands if c["sig"] not in clicked), None)
|
|
747
|
+
if nxt is None:
|
|
748
|
+
break
|
|
749
|
+
clicked.add(nxt["sig"])
|
|
750
|
+
clicks += 1
|
|
751
|
+
self.stats["clicks"] += 1
|
|
752
|
+
self._eval(_click_js(nxt["i"]))
|
|
753
|
+
self._pump(0.15)
|
|
754
|
+
self._settle(0.5, 8)
|
|
755
|
+
self._drain()
|
|
756
|
+
now = self._state_url()
|
|
757
|
+
if now and self._key(now) != self._key(url):
|
|
758
|
+
self._enqueue(now, depth + 1, url, queue)
|
|
759
|
+
if not self._navigate(url):
|
|
760
|
+
return
|
|
761
|
+
if self._key(self._state_url()) != self._key(url):
|
|
762
|
+
return
|
|
763
|
+
else:
|
|
764
|
+
self._harvest(depth, queue, url) # a menu may have revealed new links
|
|
765
|
+
try: # close a menu or dialog it opened
|
|
766
|
+
self._page("Input.dispatchKeyEvent", {"type": "keyDown", "key": "Escape", "code": "Escape",
|
|
767
|
+
"windowsVirtualKeyCode": 27}, 3)
|
|
768
|
+
self._page("Input.dispatchKeyEvent", {"type": "keyUp", "key": "Escape", "code": "Escape",
|
|
769
|
+
"windowsVirtualKeyCode": 27}, 3)
|
|
770
|
+
except (RuntimeError, TimeoutError):
|
|
771
|
+
pass
|
|
772
|
+
# press Enter in a search box, submit the forms that are safe to submit
|
|
773
|
+
filled = (self._eval(_FILL_JS) or []) or filled
|
|
774
|
+
if filled:
|
|
775
|
+
self._settle(0.3, 4)
|
|
776
|
+
if self._eval(_ENTER_JS):
|
|
777
|
+
self._settle(0.5, 6)
|
|
778
|
+
self._drain()
|
|
779
|
+
if not cfg.no_forms:
|
|
780
|
+
submitted = self._eval(_SUBMIT_JS) or 0
|
|
781
|
+
self.stats["forms_submitted"] += int(submitted)
|
|
782
|
+
if submitted:
|
|
783
|
+
self._settle(0.6, 8)
|
|
784
|
+
self._drain()
|
|
785
|
+
now = self._state_url()
|
|
786
|
+
if now and self._key(now) != self._key(url):
|
|
787
|
+
self._enqueue(now, depth + 1, url, queue)
|
|
788
|
+
self._navigate(url)
|
|
789
|
+
|
|
790
|
+
def _route_seeds(self, queue: deque, depth: int) -> None:
|
|
791
|
+
"""Router paths written into a bundle (`path:"basket"`), for hash- and history-routed SPAs."""
|
|
792
|
+
origin = f"{self.start.scheme}://{self.start.netloc}"
|
|
793
|
+
hash_mode = "#/" in (self._state_url() or "") or self.cfg.start.find("#/") != -1
|
|
794
|
+
paths: set[str] = set()
|
|
795
|
+
for _, text in self._scripts:
|
|
796
|
+
if len(text) < 2000:
|
|
797
|
+
continue
|
|
798
|
+
Q = """["'`]"""
|
|
799
|
+
for m in re.finditer(r"path\s*:\s*" + Q + r"([A-Za-z0-9_\-/]{1,40})" + Q, text):
|
|
800
|
+
p = m.group(1).strip("/")
|
|
801
|
+
if p and not _UNSAFE.search(p):
|
|
802
|
+
paths.add(p)
|
|
803
|
+
# one level of nesting: {path:`account`, ..., children:[{path:`orders`}, ...]}
|
|
804
|
+
for m in re.finditer(r"path\s*:\s*" + Q + r"([A-Za-z0-9_\-/]{1,40})" + Q
|
|
805
|
+
+ r"[^\[\]{}]{0,80}children\s*:\s*\[", text):
|
|
806
|
+
for c in re.finditer(r"path\s*:\s*" + Q + r"([A-Za-z0-9_\-/]{1,40})" + Q,
|
|
807
|
+
text[m.end(): m.end() + 900].split("]}")[0]):
|
|
808
|
+
child = c.group(1).strip("/")
|
|
809
|
+
if child and not _UNSAFE.search(child):
|
|
810
|
+
paths.add(f"{m.group(1).strip('/')}/{child}")
|
|
811
|
+
for p in sorted(paths)[:200]:
|
|
812
|
+
self._enqueue(f"{origin}/#/{p}" if hash_mode else f"{origin}/{p}", depth, origin + "/", queue, "route")
|
|
813
|
+
|
|
814
|
+
def run(self) -> CrawlResult:
|
|
815
|
+
cfg = self.cfg
|
|
816
|
+
try:
|
|
817
|
+
if cfg.auth_login:
|
|
818
|
+
self._login() # plain HTTP, once; the token is then given to the browser
|
|
819
|
+
self._launch()
|
|
820
|
+
self._setup()
|
|
821
|
+
if cfg.journeys:
|
|
822
|
+
self._run_journeys()
|
|
823
|
+
if cfg.journey_only:
|
|
824
|
+
self._settle(0.3, 3)
|
|
825
|
+
self._drain()
|
|
826
|
+
self._sync_cookies()
|
|
827
|
+
return CrawlResult(self.items, self.stats, self.forms, self.scope, self.screenshot)
|
|
828
|
+
queue: deque = deque()
|
|
829
|
+
start = cfg.start
|
|
830
|
+
self.seen.add(("GET", self._key(start)))
|
|
831
|
+
queue.append((start, 0))
|
|
832
|
+
routes_done = False
|
|
833
|
+
todo: deque = deque() # states visited, not yet clicked through
|
|
834
|
+
# Breadth first: reach every page state before spending clicks inside any of them,
|
|
835
|
+
# so a time or request budget runs out on depth, not on breadth.
|
|
836
|
+
while self._budget_left() and (queue or todo):
|
|
837
|
+
if queue:
|
|
838
|
+
url, depth = queue.popleft()
|
|
839
|
+
if depth > cfg.max_depth:
|
|
840
|
+
self.stats["skipped_cap"] += 1
|
|
841
|
+
continue
|
|
842
|
+
if not self._navigate(url):
|
|
843
|
+
self.stats["errors"] += 1
|
|
844
|
+
continue
|
|
845
|
+
self.stats["states"] += 1
|
|
846
|
+
if cfg.progress:
|
|
847
|
+
print(f"crawl: [{self.stats['requests']} req] {url}", file=sys.stderr)
|
|
848
|
+
self._harvest(depth, queue, url)
|
|
849
|
+
if cfg.screenshot and self.screenshot is None:
|
|
850
|
+
self.screenshot = self._capture()
|
|
851
|
+
todo.append((url, depth))
|
|
852
|
+
if not routes_done:
|
|
853
|
+
self._route_seeds(queue, depth + 1)
|
|
854
|
+
routes_done = True
|
|
855
|
+
else:
|
|
856
|
+
url, depth = todo.popleft()
|
|
857
|
+
if self._key(self._state_url()) != self._key(url) and not self._navigate(url):
|
|
858
|
+
continue
|
|
859
|
+
self._interact(url, depth, queue)
|
|
860
|
+
self._settle(0.3, 3)
|
|
861
|
+
self._drain()
|
|
862
|
+
self._sync_cookies()
|
|
863
|
+
finally:
|
|
864
|
+
self._close()
|
|
865
|
+
if not self.attach: # attached: the browser's own cookies are not ours to copy
|
|
866
|
+
self._static_pass()
|
|
867
|
+
return CrawlResult(self.items, self.stats, self.forms, self.scope, self.screenshot)
|
|
868
|
+
|
|
869
|
+
# ------------------------------------------------------------ journeys ---
|
|
870
|
+
_KEYS = {"Enter": (13, "\r"), "Tab": (9, ""), "Escape": (27, ""), "ArrowDown": (40, ""),
|
|
871
|
+
"ArrowUp": (38, ""), "Space": (32, " "), "Backspace": (8, "")}
|
|
872
|
+
|
|
873
|
+
def _run_journeys(self) -> None:
|
|
874
|
+
from . import journey as jy
|
|
875
|
+
self._journey_active = True
|
|
876
|
+
try:
|
|
877
|
+
for j in self.cfg.journeys:
|
|
878
|
+
variables: dict = {}
|
|
879
|
+
try:
|
|
880
|
+
for k, v in j.vars.items():
|
|
881
|
+
variables[k] = jy.substitute(v, {**variables, **self.cfg.journey_vars})
|
|
882
|
+
variables.update(self.cfg.journey_vars)
|
|
883
|
+
except jy.JourneyError as e:
|
|
884
|
+
self._journey_note(j, 0, "vars", str(e))
|
|
885
|
+
continue
|
|
886
|
+
if self.cfg.progress:
|
|
887
|
+
print(f"crawl: journey {j.name!r} ({len(j.steps)} steps)", file=sys.stderr)
|
|
888
|
+
for n, step in enumerate(j.steps, 1):
|
|
889
|
+
kind = next(k for k in step if k in jy.STEPS)
|
|
890
|
+
self.stats["journey_steps"] += 1
|
|
891
|
+
try:
|
|
892
|
+
self._step(kind, jy.substitute(step[kind], variables), variables)
|
|
893
|
+
self._pump(0.1)
|
|
894
|
+
self._drain()
|
|
895
|
+
except (jy.JourneyError, RuntimeError, TimeoutError) as e:
|
|
896
|
+
self._journey_note(j, n, kind, str(e))
|
|
897
|
+
if step.get("required"):
|
|
898
|
+
print(f"crawl: journey {j.name!r} aborted at step {n} (required)", file=sys.stderr)
|
|
899
|
+
break
|
|
900
|
+
finally:
|
|
901
|
+
self._journey_active = False
|
|
902
|
+
|
|
903
|
+
def _ensure_origin(self) -> None:
|
|
904
|
+
origin = f"{self.start.scheme}://{self.start.netloc}"
|
|
905
|
+
if (self._eval("location.origin") or "") != origin:
|
|
906
|
+
self._navigate(origin + "/")
|
|
907
|
+
|
|
908
|
+
def _step(self, kind: str, arg, variables: dict) -> None:
|
|
909
|
+
from . import journey as jy
|
|
910
|
+
origin = f"{self.start.scheme}://{self.start.netloc}"
|
|
911
|
+
if kind == "goto":
|
|
912
|
+
url = urllib.parse.urljoin(origin + "/", arg)
|
|
913
|
+
if not self.in_scope(url):
|
|
914
|
+
raise jy.JourneyError(f"{url} is outside the crawl scope")
|
|
915
|
+
if not self._navigate(url):
|
|
916
|
+
raise jy.JourneyError(f"could not load {url}")
|
|
917
|
+
elif kind == "click":
|
|
918
|
+
sel, text = (arg, "") if isinstance(arg, str) else (arg.get("selector", ""), arg.get("text", ""))
|
|
919
|
+
self._eval(_SAFE_JS)
|
|
920
|
+
ok = self._eval(
|
|
921
|
+
"((sel, text) => { const vis = window.__b2m ? window.__b2m.vis : () => true;"
|
|
922
|
+
" const label = window.__b2m ? window.__b2m.label : e => (e.innerText || '');"
|
|
923
|
+
" let els = Array.from(document.querySelectorAll(sel || 'button,a,[role=button],[role=tab],[role=menuitem],"
|
|
924
|
+
"input[type=submit],summary,[routerlink],[onclick]')).filter(vis);"
|
|
925
|
+
" if (text) els = els.filter(e => (label(e) || '').toLowerCase().includes(text.toLowerCase()));"
|
|
926
|
+
" const e = els[0]; if (!e) return false; e.scrollIntoView({block:'center'}); e.click(); return true; })(%s, %s)"
|
|
927
|
+
% (json.dumps(sel), json.dumps(text)))
|
|
928
|
+
if not ok:
|
|
929
|
+
raise jy.JourneyError(f"nothing to click for {arg!r}")
|
|
930
|
+
self._pump(0.15)
|
|
931
|
+
self._settle(0.5, 8)
|
|
932
|
+
elif kind == "fill":
|
|
933
|
+
sel = arg.get("selector") or f"[name={json.dumps(arg['name'])}]"
|
|
934
|
+
ok = self._eval(
|
|
935
|
+
"((sel, v) => { const e = document.querySelector(sel); if (!e) return false; e.focus();"
|
|
936
|
+
" const d = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(e), 'value');"
|
|
937
|
+
" d && d.set ? d.set.call(e, v) : (e.value = v);"
|
|
938
|
+
" e.dispatchEvent(new Event('input', {bubbles:true})); e.dispatchEvent(new Event('change', {bubbles:true}));"
|
|
939
|
+
" return true; })(%s, %s)" % (json.dumps(sel), json.dumps(str(arg["value"]))))
|
|
940
|
+
if not ok:
|
|
941
|
+
raise jy.JourneyError(f"no field matches {sel!r}")
|
|
942
|
+
elif kind == "press":
|
|
943
|
+
code, ch = self._KEYS.get(str(arg), (0, str(arg) if len(str(arg)) == 1 else ""))
|
|
944
|
+
for t in ("keyDown", "keyUp"):
|
|
945
|
+
self._page("Input.dispatchKeyEvent", {"type": t, "key": str(arg), "code": str(arg),
|
|
946
|
+
"windowsVirtualKeyCode": code,
|
|
947
|
+
**({"text": ch} if ch and t == "keyDown" else {})}, 5)
|
|
948
|
+
self._pump(0.15)
|
|
949
|
+
self._settle(0.5, 8)
|
|
950
|
+
elif kind == "wait":
|
|
951
|
+
self._pump(float(arg))
|
|
952
|
+
elif kind == "wait_for":
|
|
953
|
+
sel, text, timeout = (arg, "", 10.0) if isinstance(arg, str) else (
|
|
954
|
+
arg.get("selector", ""), arg.get("text", ""), float(arg.get("timeout", 10)))
|
|
955
|
+
end = time.monotonic() + timeout
|
|
956
|
+
while time.monotonic() < end:
|
|
957
|
+
if self._eval("((s, t) => { const q = s ? document.querySelector(s) : document.body;"
|
|
958
|
+
" return !!q && (!t || (q.innerText || '').includes(t)); })(%s, %s)"
|
|
959
|
+
% (json.dumps(sel), json.dumps(text))):
|
|
960
|
+
return
|
|
961
|
+
self._pump(0.2)
|
|
962
|
+
raise jy.JourneyError(f"timed out waiting for {arg!r}")
|
|
963
|
+
elif kind == "wait_idle":
|
|
964
|
+
self._settle(0.6, 12)
|
|
965
|
+
elif kind == "assert":
|
|
966
|
+
state = self._eval("({url: location.href, text: (document.body ? document.body.innerText : ''), "
|
|
967
|
+
"has: %s})" % (("!!document.querySelector(%s)" % json.dumps(arg["selector"]))
|
|
968
|
+
if arg.get("selector") else "true")) or {}
|
|
969
|
+
if arg.get("url_contains") and arg["url_contains"] not in state.get("url", ""):
|
|
970
|
+
raise jy.JourneyError(f"expected the URL to contain {arg['url_contains']!r}, got {state.get('url')!r}")
|
|
971
|
+
if arg.get("text") and arg["text"] not in state.get("text", ""):
|
|
972
|
+
raise jy.JourneyError(f"expected the page to contain {arg['text']!r}")
|
|
973
|
+
if arg.get("not_text") and arg["not_text"] in state.get("text", ""):
|
|
974
|
+
raise jy.JourneyError(f"the page contains {arg['not_text']!r}")
|
|
975
|
+
if not state.get("has", True):
|
|
976
|
+
raise jy.JourneyError(f"no element matches {arg['selector']!r}")
|
|
977
|
+
elif kind == "scroll":
|
|
978
|
+
self._eval(_SCROLL_JS)
|
|
979
|
+
self._settle(0.4, 5)
|
|
980
|
+
elif kind == "set_header":
|
|
981
|
+
self.headers.update({k: str(v) for k, v in arg.items()})
|
|
982
|
+
self._page("Network.setExtraHTTPHeaders", {"headers": self.headers})
|
|
983
|
+
elif kind == "set_cookie":
|
|
984
|
+
for k, v in arg.items():
|
|
985
|
+
self._page("Network.setCookie", {"name": k, "value": str(v), "url": origin})
|
|
986
|
+
elif kind == "set_storage":
|
|
987
|
+
self._ensure_origin()
|
|
988
|
+
js = ";".join(f"localStorage.setItem({json.dumps(k)},{json.dumps(str(v))})" for k, v in arg.items())
|
|
989
|
+
self._eval(js)
|
|
990
|
+
self._page("Page.addScriptToEvaluateOnNewDocument", {"source": "try{" + js + "}catch(e){}"})
|
|
991
|
+
elif kind == "request":
|
|
992
|
+
self._request_step(arg, origin, variables)
|
|
993
|
+
|
|
994
|
+
def _request_step(self, spec: dict, origin: str, variables: dict) -> None:
|
|
995
|
+
"""An HTTP request made from inside the page, so it carries the session and is recorded."""
|
|
996
|
+
from . import journey as jy
|
|
997
|
+
url = spec.get("url") or urllib.parse.urljoin(origin + "/", spec["path"])
|
|
998
|
+
if not self.in_scope(url):
|
|
999
|
+
raise jy.JourneyError(f"{url} is outside the crawl scope")
|
|
1000
|
+
method = spec.get("method", "GET").upper()
|
|
1001
|
+
headers = dict(spec.get("headers") or {})
|
|
1002
|
+
body = None
|
|
1003
|
+
if "json" in spec:
|
|
1004
|
+
body = json.dumps(spec["json"])
|
|
1005
|
+
headers.setdefault("Content-Type", "application/json")
|
|
1006
|
+
elif "form" in spec:
|
|
1007
|
+
body = urllib.parse.urlencode(spec["form"])
|
|
1008
|
+
headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
|
|
1009
|
+
elif "body" in spec:
|
|
1010
|
+
body = str(spec["body"])
|
|
1011
|
+
self._ensure_origin()
|
|
1012
|
+
res = self._eval(
|
|
1013
|
+
"(async (u, m, h, b) => { try { const r = await fetch(u, {method: m, headers: h, body: b, credentials: 'include'});"
|
|
1014
|
+
" const t = await r.text(); return {status: r.status, text: t.slice(0, 2000000)}; }"
|
|
1015
|
+
" catch (e) { return {status: 0, error: String(e)}; } })(%s, %s, %s, %s)"
|
|
1016
|
+
% (json.dumps(url), json.dumps(method), json.dumps(headers), json.dumps(body)), 40)
|
|
1017
|
+
if not isinstance(res, dict) or not res.get("status"):
|
|
1018
|
+
why = (res or {}).get("error", "no response") if isinstance(res, dict) else "no response"
|
|
1019
|
+
raise jy.JourneyError(f"{method} {url} was not sent or failed ({why}; blocked by scope or --read-only?)")
|
|
1020
|
+
expect = spec.get("expect")
|
|
1021
|
+
if expect is not None and res["status"] not in ([expect] if isinstance(expect, int) else expect):
|
|
1022
|
+
raise jy.JourneyError(f"{method} {url} answered {res['status']}, expected {expect}")
|
|
1023
|
+
if spec.get("extract"):
|
|
1024
|
+
try:
|
|
1025
|
+
data = json.loads(res.get("text", ""))
|
|
1026
|
+
except ValueError:
|
|
1027
|
+
data = None
|
|
1028
|
+
for name, path in spec["extract"].items():
|
|
1029
|
+
val = jy.dig(data, path) if isinstance(path, str) else None
|
|
1030
|
+
if val is None:
|
|
1031
|
+
raise jy.JourneyError(f"could not extract {name!r} from {path!r} in the response")
|
|
1032
|
+
variables[name] = val
|
|
1033
|
+
|
|
1034
|
+
def _sync_cookies(self) -> None:
|
|
1035
|
+
try:
|
|
1036
|
+
for c in self._page("Network.getAllCookies", {}, 8).get("cookies", []):
|
|
1037
|
+
host = c.get("domain", "").lstrip(".").lower()
|
|
1038
|
+
self.jar.setdefault(host or self.start.hostname.lower(), {})[c["name"]] = c["value"]
|
|
1039
|
+
except (RuntimeError, TimeoutError, ConnectionError, AttributeError):
|
|
1040
|
+
pass
|
|
1041
|
+
|
|
1042
|
+
def _static_pass(self) -> None:
|
|
1043
|
+
"""GET the concrete endpoints the bundles name that the UI never triggered."""
|
|
1044
|
+
if not self.cfg.probe_js:
|
|
1045
|
+
return
|
|
1046
|
+
pending: list[Task] = []
|
|
1047
|
+
for url, text in self._scripts:
|
|
1048
|
+
self._probe_refs(Task("GET", url, 0, self.cfg.start, "js"), text, pending)
|
|
1049
|
+
for task in pending:
|
|
1050
|
+
if not self._budget_left():
|
|
1051
|
+
break
|
|
1052
|
+
got = self.fetch(task)
|
|
1053
|
+
if got is None:
|
|
1054
|
+
continue
|
|
1055
|
+
item, *_ = got
|
|
1056
|
+
self.items.append(item)
|
|
1057
|
+
self.stats["requests"] += 1
|
|
1058
|
+
self.stats["js_probes"] += 1
|