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,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Static endpoint extraction from JavaScript and HTML bodies.
|
|
3
|
+
|
|
4
|
+
A reference found in code is a *claim* that the app may call an endpoint, not
|
|
5
|
+
proof that it does, so everything extracted here becomes an INFERRED edge in
|
|
6
|
+
the model. Each reference is a (method, host, path, kind) tuple:
|
|
7
|
+
|
|
8
|
+
method "GET", "POST", ... when the call site names it, else None
|
|
9
|
+
host the host of an absolute URL, else None (relative to the page origin)
|
|
10
|
+
path templated the same way runtime paths are (`/users/{id}`), so a
|
|
11
|
+
reference and a request for the same endpoint reconcile
|
|
12
|
+
kind "call" (fetch/axios/XHR/verb call), "string" (bare API-looking
|
|
13
|
+
string literal) or "form" (an HTML <form action>)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
import urllib.parse
|
|
20
|
+
|
|
21
|
+
from .redact import template_path
|
|
22
|
+
|
|
23
|
+
# Bodies larger than this are scanned only up to the cap and flagged.
|
|
24
|
+
MAX_SCAN_BYTES = 10 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
# Host of a reference whose base URL the code keeps in a variable (`${apiHost}/v1/accounts`):
|
|
27
|
+
# the path is known, the host is not, so it is never pinned to the page's own host.
|
|
28
|
+
VAR_BASE = "{base}"
|
|
29
|
+
|
|
30
|
+
API_HINT = re.compile(r"/(api|v\d+|graphql|rest|gql)(/|$)", re.I)
|
|
31
|
+
|
|
32
|
+
# `${expr}` inside template literals becomes a {param} placeholder.
|
|
33
|
+
_TEMPLATE_EXPR_RE = re.compile(r"\$\{[^{}`]{0,120}\}")
|
|
34
|
+
|
|
35
|
+
# A URL-ish literal: optional scheme+host, then a path. `{param}` may appear
|
|
36
|
+
# (from template literals) anywhere, including as a leading base-URL prefix.
|
|
37
|
+
_URL_BODY = r"(?:\{param\})?(?:https?:)?/[^\s\"'`<>\\]{0,300}?"
|
|
38
|
+
|
|
39
|
+
_VERB_CALL_RE = re.compile(
|
|
40
|
+
r"\.(get|post|put|patch|delete|head)\s*\(\s*([\"'`])(" + _URL_BODY + r")\2", re.I)
|
|
41
|
+
_FETCH_RE = re.compile(
|
|
42
|
+
r"(?:\b(?:fetch|axios|ajax|request)|\$http)\s*\(\s*([\"'`])(" + _URL_BODY + r")\1")
|
|
43
|
+
_XHR_OPEN_RE = re.compile(
|
|
44
|
+
r"\.open\s*\(\s*[\"'](GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)[\"']\s*,\s*"
|
|
45
|
+
r"([\"'`])(" + _URL_BODY + r")\2", re.I)
|
|
46
|
+
_METHOD_NEAR_RE = re.compile(r"""method\s*:\s*["'`](GET|POST|PUT|PATCH|DELETE|HEAD)["'`]""", re.I)
|
|
47
|
+
_STRING_RE = re.compile(r"([\"'`])(" + _URL_BODY + r")\1")
|
|
48
|
+
|
|
49
|
+
_FORM_RE = re.compile(r"<form\b([^>]*)>", re.I)
|
|
50
|
+
_ATTR_RE = re.compile(r"""\b(action|method)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))""", re.I)
|
|
51
|
+
|
|
52
|
+
_STATIC_EXT = re.compile(
|
|
53
|
+
r"\.(?:png|jpe?g|gif|svg|ico|webp|avif|css|woff2?|ttf|otf|eot|map|mp4|webm|mp3|"
|
|
54
|
+
r"html?|js|mjs|json|txt|md)$", re.I)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _normalize(raw: str) -> tuple[str | None, str] | None:
|
|
58
|
+
"""Split a raw literal into (host, templated path); None if not a path."""
|
|
59
|
+
raw = raw.strip()
|
|
60
|
+
var_base = raw.startswith("{param}")
|
|
61
|
+
if var_base:
|
|
62
|
+
raw = raw[len("{param}"):] # the base URL is a variable: the host is unknown
|
|
63
|
+
if raw.startswith("//"):
|
|
64
|
+
raw = "https:" + raw
|
|
65
|
+
host = None
|
|
66
|
+
if raw.lower().startswith(("http://", "https://")):
|
|
67
|
+
try:
|
|
68
|
+
parts = urllib.parse.urlsplit(raw)
|
|
69
|
+
except ValueError: # `https://[{x}]/…` in minified code is not a URL
|
|
70
|
+
return None
|
|
71
|
+
host = (parts.hostname or "").lower() or None
|
|
72
|
+
path = parts.path or "/"
|
|
73
|
+
else:
|
|
74
|
+
if not raw.startswith("/"):
|
|
75
|
+
return None
|
|
76
|
+
path = raw.split("?", 1)[0].split("#", 1)[0]
|
|
77
|
+
if "//" in path or len(path) > 200:
|
|
78
|
+
return None
|
|
79
|
+
# `"/api/users/" + id` — a trailing slash is a concatenation point
|
|
80
|
+
if len(path) > 1 and path.endswith("/"):
|
|
81
|
+
path = path + "{param}"
|
|
82
|
+
return host or (VAR_BASE if var_base else None), template_path(path)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _ref(method, raw, kind, out: set) -> None:
|
|
86
|
+
norm = _normalize(raw)
|
|
87
|
+
if norm is None:
|
|
88
|
+
return
|
|
89
|
+
host, path = norm
|
|
90
|
+
if path == "/" and host in (None, VAR_BASE):
|
|
91
|
+
return
|
|
92
|
+
# `${this.host}/${id}/reviews` — the base is a service prefix the bundle
|
|
93
|
+
# keeps in a variable, so all that is left is `/{param}/reviews`. That is
|
|
94
|
+
# not a path anything can be matched to; drop it rather than invent a
|
|
95
|
+
# root-level endpoint (an API-looking segment after it keeps it: `/{t}/api/x`).
|
|
96
|
+
if host in (None, VAR_BASE) and path.startswith("/{param}") and not API_HINT.search(path):
|
|
97
|
+
return
|
|
98
|
+
out.add((method.upper() if method else None, host, path, kind))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def extract_js_refs(body: str) -> set[tuple[str | None, str | None, str, str]]:
|
|
102
|
+
"""Endpoint references inside JavaScript (or inline <script>) text."""
|
|
103
|
+
out: set = set()
|
|
104
|
+
if not body:
|
|
105
|
+
return out
|
|
106
|
+
body = _TEMPLATE_EXPR_RE.sub("{param}", body)
|
|
107
|
+
call_spans: list[tuple[int, int]] = []
|
|
108
|
+
|
|
109
|
+
for m in _XHR_OPEN_RE.finditer(body):
|
|
110
|
+
_ref(m.group(1), m.group(3), "call", out)
|
|
111
|
+
call_spans.append(m.span(3))
|
|
112
|
+
for m in _VERB_CALL_RE.finditer(body):
|
|
113
|
+
_ref(m.group(1), m.group(3), "call", out)
|
|
114
|
+
call_spans.append(m.span(3))
|
|
115
|
+
for m in _FETCH_RE.finditer(body):
|
|
116
|
+
# fetch(url, {method: "POST"}) — read the method only from THIS call's
|
|
117
|
+
# options object, i.e. before the statement ends or the next call begins,
|
|
118
|
+
# so a later call's method can't bleed onto this one.
|
|
119
|
+
window = body[m.end(): m.end() + 200]
|
|
120
|
+
stop = len(window)
|
|
121
|
+
for tok in (";", "fetch(", "axios", ".open(", ".then(", "\n\n"):
|
|
122
|
+
i = window.find(tok)
|
|
123
|
+
if i != -1:
|
|
124
|
+
stop = min(stop, i)
|
|
125
|
+
near = _METHOD_NEAR_RE.search(window[:stop])
|
|
126
|
+
_ref(near.group(1) if near else None, m.group(2), "call", out)
|
|
127
|
+
call_spans.append(m.span(2))
|
|
128
|
+
|
|
129
|
+
called = {s for s in call_spans}
|
|
130
|
+
for m in _STRING_RE.finditer(body):
|
|
131
|
+
if m.span(2) in called:
|
|
132
|
+
continue
|
|
133
|
+
raw = m.group(2)
|
|
134
|
+
norm = _normalize(raw)
|
|
135
|
+
if norm is None:
|
|
136
|
+
continue
|
|
137
|
+
_, path = norm
|
|
138
|
+
# a bare string is only interesting if it looks like an API route
|
|
139
|
+
if API_HINT.search(path) and not _STATIC_EXT.search(path):
|
|
140
|
+
_ref(None, raw, "string", out)
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def extract_html_refs(body: str) -> set[tuple[str | None, str | None, str, str]]:
|
|
145
|
+
"""<form action> targets plus references inside inline scripts."""
|
|
146
|
+
out: set = set()
|
|
147
|
+
if not body:
|
|
148
|
+
return out
|
|
149
|
+
for fm in _FORM_RE.finditer(body):
|
|
150
|
+
attrs = {}
|
|
151
|
+
for am in _ATTR_RE.finditer(fm.group(1)):
|
|
152
|
+
attrs[am.group(1).lower()] = am.group(2) or am.group(3) or am.group(4) or ""
|
|
153
|
+
action = attrs.get("action", "").strip()
|
|
154
|
+
if not action or action.startswith(("#", "javascript:", "mailto:")):
|
|
155
|
+
continue
|
|
156
|
+
method = (attrs.get("method") or "GET").upper()
|
|
157
|
+
_ref(method if method in ("GET", "POST") else "GET", action, "form", out)
|
|
158
|
+
out |= extract_js_refs(body)
|
|
159
|
+
return out
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---- source maps and workers -------------------------------------------------
|
|
163
|
+
|
|
164
|
+
_SOURCEMAP_RE = re.compile(r"//[#@]\s*sourceMappingURL=([^\s'\"]+)")
|
|
165
|
+
_WORKER_RES = (
|
|
166
|
+
("worker", re.compile(r"new\s+(?:Shared)?Worker\s*\(\s*([\"'`])([^\"'`\s]{1,200})\1")),
|
|
167
|
+
("service_worker", re.compile(r"serviceWorker\s*\.\s*register\s*\(\s*([\"'`])([^\"'`\s]{1,200})\1")),
|
|
168
|
+
("import_scripts", re.compile(r"importScripts\s*\(\s*([\"'`])([^\"'`\s]{1,200})\1")),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _resolve_asset(raw: str, script_host: str, script_path: str):
|
|
173
|
+
"""Resolve a reference found in a script to (host, templated path); None if not a file path."""
|
|
174
|
+
raw = raw.strip()
|
|
175
|
+
if not raw or raw.startswith(("data:", "blob:", "{param}")) or "{param}" in raw:
|
|
176
|
+
return None
|
|
177
|
+
try:
|
|
178
|
+
parts = urllib.parse.urlsplit(raw if "://" in raw or raw.startswith("//") else
|
|
179
|
+
urllib.parse.urljoin("https://x" + script_path, raw))
|
|
180
|
+
except ValueError:
|
|
181
|
+
return None
|
|
182
|
+
if "://" in raw or raw.startswith("//"):
|
|
183
|
+
host = (parts.hostname or "").lower() or script_host
|
|
184
|
+
path = parts.path
|
|
185
|
+
else:
|
|
186
|
+
host, path = script_host, parts.path
|
|
187
|
+
if not path.startswith("/") or "//" in path or len(path) > 200:
|
|
188
|
+
return None
|
|
189
|
+
return host, template_path(path)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def extract_script_assets(body: str, host: str, path: str,
|
|
193
|
+
header_map: str | None = None) -> set[tuple[str, str, str]]:
|
|
194
|
+
"""Source maps and workers a script names: a set of (kind, host, path).
|
|
195
|
+
|
|
196
|
+
kind is "sourcemap", "worker", "service_worker" or "import_scripts". Like every code
|
|
197
|
+
reference these are claims about the code; the model records them as INFERRED."""
|
|
198
|
+
out: set = set()
|
|
199
|
+
refs = [("sourcemap", m.group(1)) for m in _SOURCEMAP_RE.finditer(body or "")]
|
|
200
|
+
if header_map:
|
|
201
|
+
refs.append(("sourcemap", header_map))
|
|
202
|
+
for kind, rx in _WORKER_RES:
|
|
203
|
+
refs += [(kind, m.group(2)) for m in rx.finditer(body or "")]
|
|
204
|
+
for kind, raw in refs:
|
|
205
|
+
r = _resolve_asset(raw, host, path)
|
|
206
|
+
if r:
|
|
207
|
+
out.add((kind, r[0], r[1]))
|
|
208
|
+
return out
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Infer what kind of value a parameter carries, from its raw value.
|
|
3
|
+
|
|
4
|
+
Runs in parse.py before values are masked, so only the label survives — the
|
|
5
|
+
value itself never reaches the model. The checks run as a waterfall, most
|
|
6
|
+
specific first, and the first match wins:
|
|
7
|
+
|
|
8
|
+
jwt -> uuid -> email -> integer -> number -> boolean -> base64 -> string
|
|
9
|
+
|
|
10
|
+
An empty value says nothing about its type and yields None.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
TYPES = ("jwt", "uuid", "email", "integer", "number", "boolean", "base64", "string")
|
|
18
|
+
|
|
19
|
+
# ASCII classes only, so the Java engine (Infer.java) labels every value the same way
|
|
20
|
+
_JWT_RE = re.compile(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*")
|
|
21
|
+
_UUID_RE = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
|
|
22
|
+
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
|
23
|
+
_INT_RE = re.compile(r"-?[0-9]{1,19}")
|
|
24
|
+
_NUM_RE = re.compile(r"-?[0-9]+\.[0-9]+(?:[eE][+-]?[0-9]+)?")
|
|
25
|
+
_B64_RE = re.compile(r"[A-Za-z0-9+/]+")
|
|
26
|
+
_B64URL_RE = re.compile(r"[A-Za-z0-9_-]+")
|
|
27
|
+
_UPPER_RE = re.compile(r"[A-Z]")
|
|
28
|
+
_LOWER_RE = re.compile(r"[a-z]")
|
|
29
|
+
_B64_TELL_RE = re.compile(r"[0-9+/=_-]")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_base64(v: str) -> bool:
|
|
33
|
+
# short alphanumerics (`admin`, `true1`) are valid base64 by accident: demand
|
|
34
|
+
# length, well-formed padding, and the mix of cases and digits words rarely have
|
|
35
|
+
core = v.rstrip("=")
|
|
36
|
+
pad = len(v) - len(core)
|
|
37
|
+
if pad > 2 or (pad and len(v) % 4) or len(core) < 12 or len(core) % 4 == 1:
|
|
38
|
+
return False
|
|
39
|
+
if not (_B64_RE.fullmatch(core) or _B64URL_RE.fullmatch(core)):
|
|
40
|
+
return False
|
|
41
|
+
return bool(_B64_TELL_RE.search(v) and _UPPER_RE.search(v) and _LOWER_RE.search(v))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def value_type(value: str | None) -> str | None:
|
|
45
|
+
"""The type label for one raw value, or None when it is empty."""
|
|
46
|
+
if value is None:
|
|
47
|
+
return None
|
|
48
|
+
v = value.strip()
|
|
49
|
+
if not v:
|
|
50
|
+
return None
|
|
51
|
+
if _JWT_RE.fullmatch(v):
|
|
52
|
+
return "jwt"
|
|
53
|
+
if _UUID_RE.fullmatch(v):
|
|
54
|
+
return "uuid"
|
|
55
|
+
if _EMAIL_RE.fullmatch(v):
|
|
56
|
+
return "email"
|
|
57
|
+
if _INT_RE.fullmatch(v):
|
|
58
|
+
return "integer"
|
|
59
|
+
if _NUM_RE.fullmatch(v):
|
|
60
|
+
return "number"
|
|
61
|
+
if v.lower() in ("true", "false"):
|
|
62
|
+
return "boolean"
|
|
63
|
+
if _is_base64(v):
|
|
64
|
+
return "base64"
|
|
65
|
+
return "string"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def param_types(location: str, params: list[tuple[str, str]]) -> list[tuple[str, str, str]]:
|
|
69
|
+
"""(location, name, type) for each parameter whose value has a type."""
|
|
70
|
+
out = []
|
|
71
|
+
for name, value in params:
|
|
72
|
+
t = value_type(value)
|
|
73
|
+
if t is not None:
|
|
74
|
+
out.append((location, name, t))
|
|
75
|
+
return out
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scripted journeys: the flows an automatic crawl cannot invent.
|
|
3
|
+
|
|
4
|
+
A crawler can click what is on the screen. It will not register an account, log
|
|
5
|
+
in, fill a checkout, upload a file or ask for something a role should not reach.
|
|
6
|
+
A journey is a short JSON file that says how, and the crawler performs it in the
|
|
7
|
+
same browser session as the crawl, so the traffic lands in the same model (and
|
|
8
|
+
an authenticated session carries on into the autonomous crawl that follows).
|
|
9
|
+
|
|
10
|
+
{
|
|
11
|
+
"name": "customer checkout",
|
|
12
|
+
"vars": {"email": "${env:SHOP_EMAIL}"},
|
|
13
|
+
"steps": [
|
|
14
|
+
{"request": {"method": "POST", "path": "/rest/user/login",
|
|
15
|
+
"json": {"email": "${email}", "password": "${env:SHOP_PASSWORD}"},
|
|
16
|
+
"extract": {"token": "authentication.token", "bid": "authentication.bid"}}},
|
|
17
|
+
{"set_header": {"Authorization": "Bearer ${token}"}},
|
|
18
|
+
{"set_storage": {"token": "${token}"}},
|
|
19
|
+
{"goto": "/#/basket"},
|
|
20
|
+
{"click": {"text": "Checkout"}},
|
|
21
|
+
{"fill": {"selector": "#address", "value": "1 Probe Street"}},
|
|
22
|
+
{"press": "Enter"},
|
|
23
|
+
{"wait_idle": true}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Steps: goto click fill press wait wait_for wait_idle request set_header
|
|
28
|
+
set_storage set_cookie scroll assert (browser mode; a static crawl runs
|
|
29
|
+
goto, request, set_header and set_cookie only)
|
|
30
|
+
|
|
31
|
+
`assert` checks where the journey has got to: {"assert": {"url_contains": "/welcome",
|
|
32
|
+
"text": "Sign out", "not_text": "Invalid password", "selector": "#account"}}. A login
|
|
33
|
+
that did not log in is the usual reason, and without it the crawl would carry on
|
|
34
|
+
anonymously and say nothing.
|
|
35
|
+
|
|
36
|
+
Values may use ${name} (a var, or something an earlier `extract` saved) and
|
|
37
|
+
${env:NAME} (an environment variable: keep passwords out of the file and out of
|
|
38
|
+
your shell history). Each step may carry "required": true to abort the journey if
|
|
39
|
+
it fails; otherwise a failure is reported and the journey carries on.
|
|
40
|
+
|
|
41
|
+
A journey is deliberate, so its requests are not held to the crawler's
|
|
42
|
+
"looks destructive" filter. They are still held to the scope, to --read-only, and
|
|
43
|
+
to --yes.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import json
|
|
49
|
+
import os
|
|
50
|
+
import re
|
|
51
|
+
from dataclasses import dataclass, field
|
|
52
|
+
|
|
53
|
+
STEPS = {"goto", "click", "fill", "press", "wait", "wait_for", "wait_idle", "request",
|
|
54
|
+
"set_header", "set_storage", "set_cookie", "scroll", "assert"}
|
|
55
|
+
BROWSER_ONLY = {"click", "fill", "press", "wait_for", "wait_idle", "set_storage", "scroll", "assert"}
|
|
56
|
+
_VAR = re.compile(r"\$\{(env:)?([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class JourneyError(ValueError):
|
|
60
|
+
"""A journey file that cannot be run (bad JSON, unknown step, missing variable)."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class Journey:
|
|
65
|
+
name: str
|
|
66
|
+
vars: dict = field(default_factory=dict)
|
|
67
|
+
steps: list = field(default_factory=list)
|
|
68
|
+
path: str = ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load(path: str) -> Journey:
|
|
72
|
+
try:
|
|
73
|
+
with open(path, encoding="utf-8") as f:
|
|
74
|
+
data = json.load(f)
|
|
75
|
+
except OSError as e:
|
|
76
|
+
raise JourneyError(f"cannot read journey {path}: {e}")
|
|
77
|
+
except ValueError as e:
|
|
78
|
+
raise JourneyError(f"{path} is not valid JSON: {e}")
|
|
79
|
+
if isinstance(data, list):
|
|
80
|
+
data = {"steps": data}
|
|
81
|
+
if not isinstance(data, dict) or not isinstance(data.get("steps"), list) or not data["steps"]:
|
|
82
|
+
raise JourneyError(f"{path}: a journey needs a non-empty \"steps\" list")
|
|
83
|
+
j = Journey(name=str(data.get("name") or os.path.basename(path)), vars=dict(data.get("vars") or {}),
|
|
84
|
+
steps=data["steps"], path=path)
|
|
85
|
+
for n, step in enumerate(j.steps, 1):
|
|
86
|
+
validate_step(step, f"{path} step {n}")
|
|
87
|
+
return j
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def validate_step(step, where: str) -> None:
|
|
91
|
+
if not isinstance(step, dict):
|
|
92
|
+
raise JourneyError(f"{where}: a step is an object like {{\"goto\": \"/\"}}")
|
|
93
|
+
kinds = [k for k in step if k in STEPS]
|
|
94
|
+
if len(kinds) != 1:
|
|
95
|
+
extra = [k for k in step if k not in STEPS and k not in ("required", "label")]
|
|
96
|
+
raise JourneyError(f"{where}: exactly one of {sorted(STEPS)} per step"
|
|
97
|
+
+ (f" (unknown key {extra[0]!r})" if extra else ""))
|
|
98
|
+
kind, arg = kinds[0], step[kinds[0]]
|
|
99
|
+
if kind == "goto" and not isinstance(arg, str):
|
|
100
|
+
raise JourneyError(f"{where}: goto takes a URL or path string")
|
|
101
|
+
if kind in ("click", "fill", "wait_for") and not isinstance(arg, (str, dict)):
|
|
102
|
+
raise JourneyError(f"{where}: {kind} takes a selector or {{\"text\": ...}}")
|
|
103
|
+
if kind == "fill" and not (isinstance(arg, dict) and "value" in arg and ("selector" in arg or "name" in arg)):
|
|
104
|
+
raise JourneyError(f"{where}: fill takes {{\"selector\": ..., \"value\": ...}}")
|
|
105
|
+
if kind == "request":
|
|
106
|
+
if not (isinstance(arg, dict) and arg.get("path") or isinstance(arg, dict) and arg.get("url")):
|
|
107
|
+
raise JourneyError(f"{where}: request takes {{\"method\": ..., \"path\": ...}}")
|
|
108
|
+
if kind == "assert" and not (isinstance(arg, dict) and set(arg) & {"url_contains", "text", "not_text", "selector"}):
|
|
109
|
+
raise JourneyError(f"{where}: assert takes url_contains, text, not_text or selector")
|
|
110
|
+
if kind in ("set_header", "set_storage", "set_cookie") and not isinstance(arg, dict):
|
|
111
|
+
raise JourneyError(f"{where}: {kind} takes an object of name: value")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def substitute(value, variables: dict):
|
|
115
|
+
"""Replace ${name} and ${env:NAME} anywhere in a step; a missing one is an error."""
|
|
116
|
+
if isinstance(value, str):
|
|
117
|
+
# a value that is exactly one ${var} keeps the variable's type, so an extracted
|
|
118
|
+
# id stays a JSON number instead of becoming "7"
|
|
119
|
+
whole = _VAR.fullmatch(value)
|
|
120
|
+
if whole and not whole.group(1) and whole.group(2) in variables:
|
|
121
|
+
return variables[whole.group(2)]
|
|
122
|
+
|
|
123
|
+
def one(m: re.Match) -> str:
|
|
124
|
+
name = m.group(2)
|
|
125
|
+
if m.group(1):
|
|
126
|
+
if name not in os.environ:
|
|
127
|
+
raise JourneyError(f"environment variable {name} is not set (used as ${{env:{name}}})")
|
|
128
|
+
return os.environ[name]
|
|
129
|
+
if name not in variables:
|
|
130
|
+
raise JourneyError(f"variable ${{{name}}} is not defined (set it under \"vars\", "
|
|
131
|
+
"pass --var, or extract it in an earlier step)")
|
|
132
|
+
return str(variables[name])
|
|
133
|
+
return _VAR.sub(one, value)
|
|
134
|
+
if isinstance(value, dict):
|
|
135
|
+
return {k: substitute(v, variables) for k, v in value.items()}
|
|
136
|
+
if isinstance(value, list):
|
|
137
|
+
return [substitute(v, variables) for v in value]
|
|
138
|
+
return value
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def dig(node, path: str):
|
|
142
|
+
"""`authentication.token`, `data.0.id` -> the value, or None."""
|
|
143
|
+
for part in path.split("."):
|
|
144
|
+
try:
|
|
145
|
+
node = node[int(part)] if isinstance(node, list) else node[part]
|
|
146
|
+
except (KeyError, IndexError, ValueError, TypeError):
|
|
147
|
+
return None
|
|
148
|
+
return node
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Live capture: exchanges recorded while you browse are masked as they arrive, appended to
|
|
3
|
+
`inputs/browse.jsonl`, and the model is rebuilt every few seconds while anything new comes in.
|
|
4
|
+
|
|
5
|
+
Only the masked record is ever written; a restart continues where the last session stopped.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from xml.etree.ElementTree import Element
|
|
15
|
+
|
|
16
|
+
from .parse import Exchange, parse_elements
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Live:
|
|
20
|
+
"""Masked exchanges waiting to be saved, and the file they are appended to."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, path: str, role: str | None = None):
|
|
23
|
+
self.path = path
|
|
24
|
+
self.role = role
|
|
25
|
+
self.lock = threading.Lock()
|
|
26
|
+
self.pending: list[Exchange] = []
|
|
27
|
+
self.stats = {"items": 0, "parsed": 0, "skipped": 0}
|
|
28
|
+
self.received = 0
|
|
29
|
+
self.saved = 0
|
|
30
|
+
if os.path.exists(path):
|
|
31
|
+
with open(path, encoding="utf-8") as f:
|
|
32
|
+
self.saved = sum(1 for line in f if line.strip() and '"_stats"' not in line[:12])
|
|
33
|
+
|
|
34
|
+
def ingest(self, items: list[Element]) -> tuple[int, int]:
|
|
35
|
+
"""Parse and mask each recorded <item>; returns (accepted, skipped)."""
|
|
36
|
+
stats: dict = {}
|
|
37
|
+
parsed = list(parse_elements(items, source="live", role=self.role, stats=stats))
|
|
38
|
+
skipped = len(items) - len(parsed)
|
|
39
|
+
with self.lock:
|
|
40
|
+
for ex in parsed:
|
|
41
|
+
ex.item = self.saved + len(self.pending)
|
|
42
|
+
self.pending.append(ex)
|
|
43
|
+
self.stats["items"] += len(items)
|
|
44
|
+
self.stats["parsed"] += len(parsed)
|
|
45
|
+
self.stats["skipped"] += skipped
|
|
46
|
+
self.received += len(parsed)
|
|
47
|
+
return len(parsed), skipped
|
|
48
|
+
|
|
49
|
+
def flush(self) -> int:
|
|
50
|
+
"""Append what is pending to the input file in one write; returns how many were saved."""
|
|
51
|
+
with self.lock:
|
|
52
|
+
batch, stats = self.pending, self.stats
|
|
53
|
+
self.pending, self.stats = [], {"items": 0, "parsed": 0, "skipped": 0}
|
|
54
|
+
if not batch and not stats["items"]:
|
|
55
|
+
return 0
|
|
56
|
+
lines = [json.dumps(ex.to_record()) for ex in batch]
|
|
57
|
+
lines.append(json.dumps({"_stats": stats, "_version": 1}))
|
|
58
|
+
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
|
59
|
+
with open(self.path, "a", encoding="utf-8") as f:
|
|
60
|
+
f.write("\n".join(lines) + "\n")
|
|
61
|
+
f.flush()
|
|
62
|
+
os.fsync(f.fileno())
|
|
63
|
+
self.saved += len(batch)
|
|
64
|
+
return len(batch)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Rebuilder(threading.Thread):
|
|
68
|
+
"""Every `interval` seconds, saves what arrived and, if anything did, rebuilds.
|
|
69
|
+
|
|
70
|
+
`report` gets one dict per rebuild: {"added", "saved", "summary", "seconds"}, or {"error"}."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, live: Live, rebuild, interval: float, report=print):
|
|
73
|
+
super().__init__(daemon=True)
|
|
74
|
+
self.live, self.rebuild, self.interval, self.report = live, rebuild, interval, report
|
|
75
|
+
self.stop = threading.Event()
|
|
76
|
+
self.last = 0.0 # how long the last rebuild took
|
|
77
|
+
|
|
78
|
+
def step(self) -> None:
|
|
79
|
+
n = self.live.flush()
|
|
80
|
+
if not n:
|
|
81
|
+
return
|
|
82
|
+
t0 = time.monotonic()
|
|
83
|
+
try:
|
|
84
|
+
summary = self.rebuild()
|
|
85
|
+
except Exception as e: # a bad rebuild must not stop the listener
|
|
86
|
+
self.report({"error": str(e)})
|
|
87
|
+
else:
|
|
88
|
+
self.report({"added": n, "saved": self.live.saved, "summary": summary,
|
|
89
|
+
"seconds": time.monotonic() - t0})
|
|
90
|
+
self.last = time.monotonic() - t0
|
|
91
|
+
|
|
92
|
+
def run(self) -> None:
|
|
93
|
+
# a big session rebuilds slowly: wait at least twice as long as the last rebuild took,
|
|
94
|
+
# so the listener never spends more than a third of its time rebuilding
|
|
95
|
+
while not self.stop.wait(max(self.interval, 2 * self.last)):
|
|
96
|
+
self.step()
|