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,248 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Export a model's API endpoints as an OpenAPI 3.0 document.
|
|
3
|
+
|
|
4
|
+
Only what the capture shows goes in: each operation is an endpoint the traffic
|
|
5
|
+
reached (or, with `include_static`, one the client code references), its
|
|
6
|
+
parameters are the names seen on the wire, and their schemas come from the
|
|
7
|
+
type each raw value had (see infer.py). Response codes are the statuses
|
|
8
|
+
observed; their bodies are not modelled. Every operation carries the evidence
|
|
9
|
+
ids that prove it, so a reader can go from the spec back to the request.
|
|
10
|
+
|
|
11
|
+
Paths are host-independent in OpenAPI, so each operation lists the server(s)
|
|
12
|
+
it was seen on.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
|
|
20
|
+
from ..core.model import Model
|
|
21
|
+
from ..ingest.extract import VAR_BASE
|
|
22
|
+
|
|
23
|
+
OPENAPI_VERSION = "3.0.3"
|
|
24
|
+
_METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
|
|
25
|
+
_PLACEHOLDER_RE = re.compile(r"\{([^/{}]*)\}")
|
|
26
|
+
|
|
27
|
+
# schema for each inferred value type
|
|
28
|
+
_TYPE_SCHEMA = {
|
|
29
|
+
"jwt": {"type": "string", "description": "JSON Web Token"},
|
|
30
|
+
"uuid": {"type": "string", "format": "uuid"},
|
|
31
|
+
"email": {"type": "string", "format": "email"},
|
|
32
|
+
"integer": {"type": "integer"},
|
|
33
|
+
"number": {"type": "number"},
|
|
34
|
+
"boolean": {"type": "boolean"},
|
|
35
|
+
"base64": {"type": "string", "format": "byte"},
|
|
36
|
+
"string": {"type": "string"},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# schema for each path placeholder redact.py writes
|
|
40
|
+
_PLACEHOLDER_SCHEMA = {
|
|
41
|
+
"id": {"type": "string", "description": "numeric or opaque identifier"},
|
|
42
|
+
"uuid": {"type": "string", "format": "uuid"},
|
|
43
|
+
"hash": {"type": "string", "pattern": "^[0-9a-fA-F]+$"},
|
|
44
|
+
"email": {"type": "string", "format": "email"},
|
|
45
|
+
"jwt": {"type": "string", "description": "JSON Web Token"},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def schema_for(types: list[str]) -> dict:
|
|
50
|
+
"""One schema for the types a parameter was seen with; mixed types become a oneOf."""
|
|
51
|
+
schemas = []
|
|
52
|
+
for t in sorted(set(types or [])):
|
|
53
|
+
s = _TYPE_SCHEMA.get(t, _TYPE_SCHEMA["string"])
|
|
54
|
+
if s not in schemas:
|
|
55
|
+
schemas.append(dict(s))
|
|
56
|
+
if not schemas:
|
|
57
|
+
return {"type": "string"}
|
|
58
|
+
if len(schemas) == 1:
|
|
59
|
+
return schemas[0]
|
|
60
|
+
return {"oneOf": schemas}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _path_params(path: str) -> tuple[str, list[dict]]:
|
|
64
|
+
"""Rewrite placeholders to unique names (`/a/{id}/b/{id}` -> `{id}`, `{id2}`) and describe them."""
|
|
65
|
+
seen: dict[str, int] = defaultdict(int)
|
|
66
|
+
params: list[dict] = []
|
|
67
|
+
|
|
68
|
+
def rename(m: re.Match) -> str:
|
|
69
|
+
base = re.sub(r"[^A-Za-z0-9_]", "", m.group(1)) or "param"
|
|
70
|
+
seen[base] += 1
|
|
71
|
+
name = base if seen[base] == 1 else f"{base}{seen[base]}"
|
|
72
|
+
params.append({"name": name, "in": "path", "required": True,
|
|
73
|
+
"schema": dict(_PLACEHOLDER_SCHEMA.get(base, {"type": "string"}))})
|
|
74
|
+
return "{" + name + "}"
|
|
75
|
+
|
|
76
|
+
return _PLACEHOLDER_RE.sub(rename, path), params
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_TOKEN_RE = re.compile(r"\.([^.\[\]]+)|\[(\d*)\]")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _json_schema(fields: list[tuple[str, list[str]]]) -> dict:
|
|
83
|
+
"""Rebuild a nested object schema from JSONPath-style names (`$.user.tags[]`)."""
|
|
84
|
+
root: dict = {}
|
|
85
|
+
for name, types in fields:
|
|
86
|
+
tokens = [(m.group(1), m.group(2)) for m in _TOKEN_RE.finditer(name[1:])]
|
|
87
|
+
if not tokens: # a bare scalar body (`"abc"`, `42`)
|
|
88
|
+
root.update(schema_for(types))
|
|
89
|
+
continue
|
|
90
|
+
node = root
|
|
91
|
+
for pos, (key, idx) in enumerate(tokens):
|
|
92
|
+
last = pos == len(tokens) - 1
|
|
93
|
+
if key == "{key}": # a map keyed by values (ids, emails): any key
|
|
94
|
+
node.setdefault("type", "object")
|
|
95
|
+
node = node.setdefault("additionalProperties", {})
|
|
96
|
+
elif key is not None:
|
|
97
|
+
node.setdefault("type", "object")
|
|
98
|
+
props = node.setdefault("properties", {})
|
|
99
|
+
node = props.setdefault(key, {})
|
|
100
|
+
else:
|
|
101
|
+
node.setdefault("type", "array")
|
|
102
|
+
node = node.setdefault("items", {})
|
|
103
|
+
if last and "type" not in node:
|
|
104
|
+
node.update(schema_for(types))
|
|
105
|
+
return root or {"type": "object"}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _request_body(body: list[tuple[str, list[str]]]) -> dict | None:
|
|
109
|
+
if not body:
|
|
110
|
+
return None
|
|
111
|
+
json_fields = [(n, t) for n, t in body if n.startswith("$")]
|
|
112
|
+
form_fields = [(n, t) for n, t in body if not n.startswith("$")]
|
|
113
|
+
content: dict = {}
|
|
114
|
+
if json_fields:
|
|
115
|
+
content["application/json"] = {"schema": _json_schema(json_fields)}
|
|
116
|
+
if form_fields:
|
|
117
|
+
content["application/x-www-form-urlencoded"] = {"schema": {
|
|
118
|
+
"type": "object",
|
|
119
|
+
"properties": {n: schema_for(t) for n, t in sorted(form_fields)},
|
|
120
|
+
}}
|
|
121
|
+
return {"content": content}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _responses(statuses: list, fields: dict[str, dict[str, list[str]]]) -> dict:
|
|
125
|
+
"""One response per observed status; a JSON schema where a JSON body was seen with it."""
|
|
126
|
+
out = {}
|
|
127
|
+
for st in sorted({s for s in statuses if s is not None}):
|
|
128
|
+
out[str(st)] = {"description": "Observed"}
|
|
129
|
+
if fields.get(str(st)):
|
|
130
|
+
out[str(st)]["content"] = {"application/json": {"schema": _json_schema(sorted(fields[str(st)].items()))}}
|
|
131
|
+
return out or {"default": {"description": "No response observed"}}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _op_id(method: str, path: str, taken: set[str]) -> str:
|
|
135
|
+
words = [w for w in re.split(r"[^A-Za-z0-9]+", path) if w]
|
|
136
|
+
base = method + "".join(w[:1].upper() + w[1:] for w in words) or method
|
|
137
|
+
oid, n = base, 1
|
|
138
|
+
while oid in taken:
|
|
139
|
+
n += 1
|
|
140
|
+
oid = f"{base}{n}"
|
|
141
|
+
taken.add(oid)
|
|
142
|
+
return oid
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _server_urls(m: Model, host: str) -> list[str]:
|
|
146
|
+
"""Every origin the capture reached `host` on, from the host node's schemes and ports."""
|
|
147
|
+
hn = m.nodes.get(f"host:{host}")
|
|
148
|
+
schemes = (hn.attrs.get("schemes") if hn else None) or ["https"]
|
|
149
|
+
ports = (hn.attrs.get("ports") if hn else None) or [None]
|
|
150
|
+
default = {"http": 80, "https": 443}
|
|
151
|
+
urls = set()
|
|
152
|
+
for sch in schemes:
|
|
153
|
+
for port in ports:
|
|
154
|
+
if port is None or port == default.get(sch):
|
|
155
|
+
urls.add(f"{sch}://{host}")
|
|
156
|
+
elif port not in default.values(): # http:443 / https:80 are pairing artefacts
|
|
157
|
+
urls.add(f"{sch}://{host}:{port}")
|
|
158
|
+
return sorted(urls) or [f"https://{host}"]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def to_openapi(m: Model, host: str | None = None, include_static: bool = False,
|
|
162
|
+
title: str | None = None, version: str = "observed") -> dict:
|
|
163
|
+
"""The model's endpoints as an OpenAPI 3.0 document (a plain dict, ready for json.dump)."""
|
|
164
|
+
params_of: dict[str, list] = defaultdict(list)
|
|
165
|
+
for e in m.edges:
|
|
166
|
+
if e.type == "USES_PARAMETER" and e.dst in m.nodes:
|
|
167
|
+
params_of[e.src].append(m.nodes[e.dst])
|
|
168
|
+
|
|
169
|
+
# (path, method) -> the endpoint nodes behind it, across hosts
|
|
170
|
+
ops: dict[tuple[str, str], list] = defaultdict(list)
|
|
171
|
+
for n in m.nodes.values():
|
|
172
|
+
if n.type != "endpoint":
|
|
173
|
+
continue
|
|
174
|
+
if host and n.attrs.get("host") != host:
|
|
175
|
+
continue
|
|
176
|
+
if n.attrs.get("host") == VAR_BASE: # its server is a variable in the code: unknown
|
|
177
|
+
continue
|
|
178
|
+
state = n.attrs.get("api_state")
|
|
179
|
+
if state == "STATIC_ONLY" and not include_static:
|
|
180
|
+
continue
|
|
181
|
+
method = (n.attrs.get("method") or "").lower()
|
|
182
|
+
if method == "*" or not method:
|
|
183
|
+
method = "get" # a reference whose method the code did not name
|
|
184
|
+
if method not in _METHODS:
|
|
185
|
+
continue
|
|
186
|
+
ops[(n.attrs.get("path") or "/", method)].append(n)
|
|
187
|
+
|
|
188
|
+
paths: dict[str, dict] = {}
|
|
189
|
+
hosts: set[str] = set()
|
|
190
|
+
taken: set[str] = set()
|
|
191
|
+
for (raw_path, method), nodes in sorted(ops.items()):
|
|
192
|
+
path, path_params = _path_params(raw_path if raw_path.startswith("/") else "/" + raw_path)
|
|
193
|
+
query: dict[str, list[str]] = defaultdict(list)
|
|
194
|
+
body: dict[str, list[str]] = defaultdict(list)
|
|
195
|
+
statuses, evidence, creds, states, op_hosts = [], set(), set(), set(), set()
|
|
196
|
+
resp_fields: dict[str, dict[str, list[str]]] = {}
|
|
197
|
+
method_known = True
|
|
198
|
+
for n in nodes:
|
|
199
|
+
op_hosts.add(n.attrs.get("host"))
|
|
200
|
+
statuses.extend(n.attrs.get("statuses") or [])
|
|
201
|
+
evidence.update(n.evidence)
|
|
202
|
+
creds.update(n.attrs.get("credentials") or [])
|
|
203
|
+
states.add(n.attrs.get("api_state"))
|
|
204
|
+
for st, fs in (n.attrs.get("response_fields") or {}).items():
|
|
205
|
+
for fname, types in fs.items():
|
|
206
|
+
merged = resp_fields.setdefault(st, {}).setdefault(fname, [])
|
|
207
|
+
merged.extend(t for t in types if t not in merged)
|
|
208
|
+
if n.attrs.get("method_known") is False:
|
|
209
|
+
method_known = False
|
|
210
|
+
for p in params_of.get(n.id, []):
|
|
211
|
+
bucket = query if p.attrs.get("location") == "query" else body
|
|
212
|
+
bucket[p.label].extend(p.attrs.get("types") or [])
|
|
213
|
+
hosts.update(h for h in op_hosts if h)
|
|
214
|
+
|
|
215
|
+
op: dict = {
|
|
216
|
+
"operationId": _op_id(method, path, taken),
|
|
217
|
+
"summary": f"{method.upper()} {path}",
|
|
218
|
+
"parameters": path_params + [
|
|
219
|
+
{"name": name, "in": "query", "required": False, "schema": schema_for(types)}
|
|
220
|
+
for name, types in sorted(query.items())
|
|
221
|
+
],
|
|
222
|
+
"responses": _responses(statuses, resp_fields),
|
|
223
|
+
"servers": [{"url": u} for h in sorted(h for h in op_hosts if h) for u in _server_urls(m, h)],
|
|
224
|
+
"x-proxy2model-api-state": sorted(s for s in states if s),
|
|
225
|
+
"x-proxy2model-evidence": sorted(evidence),
|
|
226
|
+
}
|
|
227
|
+
rb = _request_body(sorted(body.items()))
|
|
228
|
+
if rb:
|
|
229
|
+
op["requestBody"] = rb
|
|
230
|
+
if creds:
|
|
231
|
+
op["x-proxy2model-credentials"] = sorted(creds)
|
|
232
|
+
if not method_known:
|
|
233
|
+
op["x-proxy2model-method-known"] = False
|
|
234
|
+
if not op["parameters"]:
|
|
235
|
+
del op["parameters"]
|
|
236
|
+
paths.setdefault(path, {})[method] = op
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
"openapi": OPENAPI_VERSION,
|
|
240
|
+
"info": {
|
|
241
|
+
"title": title or f"{m.name} (observed)",
|
|
242
|
+
"version": version,
|
|
243
|
+
"description": "Generated by proxy2model from captured traffic. Only observed "
|
|
244
|
+
"endpoints, parameters, status codes and response fields are included.",
|
|
245
|
+
},
|
|
246
|
+
"servers": [{"url": u} for h in sorted(hosts) for u in _server_urls(m, h)],
|
|
247
|
+
"paths": paths,
|
|
248
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Reading traffic in: exports, live pushes and crawls, parsed and masked."""
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Record a browser you use yourself: every in-scope request any tab makes, with its response.
|
|
3
|
+
|
|
4
|
+
`proxy2model browse` opens Chrome with a fresh profile and attaches to it over the DevTools
|
|
5
|
+
Protocol. Every tab, popup, frame and worker is attached as it appears, paused until its network
|
|
6
|
+
is being recorded, so even the first request of a new tab is kept. Out-of-scope requests are never
|
|
7
|
+
recorded; nothing is blocked, so the app works as it would without proxy2model.
|
|
8
|
+
|
|
9
|
+
Recorded exchanges become Burp-style <item>s and go through the same parser and masking as an
|
|
10
|
+
export (see live.py); nothing raw is written.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from xml.etree.ElementTree import Element
|
|
19
|
+
|
|
20
|
+
from .browser import _Rec, CDP, WebSocket, rec_to_item
|
|
21
|
+
|
|
22
|
+
# targets whose traffic is the app's: tabs, popups, out-of-process frames, workers
|
|
23
|
+
_RECORDED = frozenset({"page", "iframe", "worker", "shared_worker", "service_worker"})
|
|
24
|
+
# images, fonts, media and stylesheets name no endpoint and carry no app logic: not recorded
|
|
25
|
+
_SKIPPED = frozenset({"Image", "Font", "Media", "Stylesheet"})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Recorder(threading.Thread):
|
|
29
|
+
"""Records one browser over its own DevTools connection until stopped or the browser goes away.
|
|
30
|
+
|
|
31
|
+
`on_items` gets a list of <item>s about twice a second while anything new was recorded."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, ws_url: str, in_scope, on_items, max_body: int = 5 * 1024 * 1024):
|
|
34
|
+
super().__init__(daemon=True)
|
|
35
|
+
self.ws_url, self.in_scope, self.on_items, self.max_body = ws_url, in_scope, on_items, max_body
|
|
36
|
+
self.stop = threading.Event()
|
|
37
|
+
self.ready = threading.Event()
|
|
38
|
+
self.recs: dict[tuple[str, str], _Rec] = {}
|
|
39
|
+
self.done: list[_Rec] = []
|
|
40
|
+
self.recorded = 0
|
|
41
|
+
self.assets_skipped = 0
|
|
42
|
+
self.seen_once: set[tuple] = set()
|
|
43
|
+
self.repeats_skipped = 0
|
|
44
|
+
self.sockets: dict[tuple[str, str], _Rec] = {} # WebSocket handshakes being recorded
|
|
45
|
+
self.frames = 0
|
|
46
|
+
self.error: str | None = None
|
|
47
|
+
|
|
48
|
+
def run(self) -> None:
|
|
49
|
+
try:
|
|
50
|
+
self.cdp = CDP(WebSocket(self.ws_url))
|
|
51
|
+
self.cdp.call("Target.setAutoAttach", {"autoAttach": True, "waitForDebuggerOnStart": True,
|
|
52
|
+
"flatten": True})
|
|
53
|
+
self.ready.set()
|
|
54
|
+
last = time.monotonic()
|
|
55
|
+
while not self.stop.is_set():
|
|
56
|
+
ev = self.cdp.next_event(0.2)
|
|
57
|
+
if ev is not None:
|
|
58
|
+
self._on_event(ev)
|
|
59
|
+
if self.done and time.monotonic() - last > 0.5:
|
|
60
|
+
self._emit()
|
|
61
|
+
last = time.monotonic()
|
|
62
|
+
except (ConnectionError, OSError):
|
|
63
|
+
pass # the browser was closed
|
|
64
|
+
except Exception as e: # report, never take the session down with it
|
|
65
|
+
self.error = f"{type(e).__name__}: {e}"
|
|
66
|
+
finally:
|
|
67
|
+
self.ready.set()
|
|
68
|
+
self._emit()
|
|
69
|
+
|
|
70
|
+
def _emit(self) -> None:
|
|
71
|
+
items: list[Element] = []
|
|
72
|
+
while self.done:
|
|
73
|
+
item = rec_to_item(self.done.pop(0), self.in_scope)
|
|
74
|
+
if item is not None:
|
|
75
|
+
items.append(item)
|
|
76
|
+
if items:
|
|
77
|
+
self.recorded += len(items)
|
|
78
|
+
self.on_items(items)
|
|
79
|
+
|
|
80
|
+
def _call(self, method: str, params: dict | None, session: str, timeout: float = 10.0):
|
|
81
|
+
try:
|
|
82
|
+
return self.cdp.call(method, params, session, timeout)
|
|
83
|
+
except (RuntimeError, TimeoutError):
|
|
84
|
+
return {}
|
|
85
|
+
|
|
86
|
+
def _on_event(self, ev: dict) -> None:
|
|
87
|
+
m, p, sid = ev["method"], ev.get("params", {}), ev.get("sessionId")
|
|
88
|
+
if m == "Target.attachedToTarget":
|
|
89
|
+
child, kind = p["sessionId"], p["targetInfo"].get("type")
|
|
90
|
+
if kind in _RECORDED:
|
|
91
|
+
self._call("Network.enable", {}, child)
|
|
92
|
+
# a cached script or page would be recorded without its body, or not at all
|
|
93
|
+
self._call("Network.setCacheDisabled", {"cacheDisabled": True}, child)
|
|
94
|
+
if kind in ("page", "iframe"): # its frames and workers, as they appear
|
|
95
|
+
self._call("Target.setAutoAttach", {"autoAttach": True, "waitForDebuggerOnStart": True,
|
|
96
|
+
"flatten": True}, child)
|
|
97
|
+
if p.get("waitingForDebugger"):
|
|
98
|
+
self._call("Runtime.runIfWaitingForDebugger", {}, child)
|
|
99
|
+
return
|
|
100
|
+
if m == "Target.detachedFromTarget":
|
|
101
|
+
gone = p.get("sessionId")
|
|
102
|
+
for key in [k for k in self.recs if k[0] == gone]:
|
|
103
|
+
del self.recs[key]
|
|
104
|
+
return
|
|
105
|
+
if not sid or not m.startswith("Network."):
|
|
106
|
+
return
|
|
107
|
+
key = (sid, p.get("requestId", ""))
|
|
108
|
+
if m.startswith("Network.webSocket"):
|
|
109
|
+
self._socket(m, p, key)
|
|
110
|
+
return
|
|
111
|
+
if m == "Network.requestWillBeSent":
|
|
112
|
+
if p.get("redirectResponse") and key in self.recs:
|
|
113
|
+
old = self.recs.pop(key)
|
|
114
|
+
old.resp, old.redirect = p["redirectResponse"], True
|
|
115
|
+
self.done.append(old)
|
|
116
|
+
if not self.in_scope(p["request"].get("url", "")):
|
|
117
|
+
return
|
|
118
|
+
if p.get("type") in _SKIPPED:
|
|
119
|
+
self.assets_skipped += 1
|
|
120
|
+
return
|
|
121
|
+
# with the cache off, every page load fetches each script and CORS preflight again;
|
|
122
|
+
# the first copy says all the later ones would
|
|
123
|
+
if p.get("type") == "Script" or p["request"].get("method") == "OPTIONS":
|
|
124
|
+
once = (p["request"].get("method"), p["request"].get("url"))
|
|
125
|
+
if once in self.seen_once:
|
|
126
|
+
self.repeats_skipped += 1
|
|
127
|
+
return
|
|
128
|
+
self.seen_once.add(once)
|
|
129
|
+
rec = _Rec(key[1])
|
|
130
|
+
rec.req, rec.rtype = p["request"], p.get("type", "")
|
|
131
|
+
rec.post = rec.req.get("postData")
|
|
132
|
+
self.recs[key] = rec
|
|
133
|
+
elif key not in self.recs:
|
|
134
|
+
return
|
|
135
|
+
elif m == "Network.requestWillBeSentExtraInfo":
|
|
136
|
+
self.recs[key].extra_req = p.get("headers")
|
|
137
|
+
elif m == "Network.responseReceived":
|
|
138
|
+
self.recs[key].resp = p["response"]
|
|
139
|
+
elif m == "Network.responseReceivedExtraInfo":
|
|
140
|
+
self.recs[key].extra_resp = p.get("headers")
|
|
141
|
+
elif m == "Network.loadingFinished":
|
|
142
|
+
rec = self.recs.pop(key)
|
|
143
|
+
if rec.resp:
|
|
144
|
+
self._body(sid, rec)
|
|
145
|
+
self.done.append(rec)
|
|
146
|
+
elif m == "Network.loadingFailed":
|
|
147
|
+
self.recs.pop(key, None)
|
|
148
|
+
|
|
149
|
+
def _socket(self, m: str, p: dict, key) -> None:
|
|
150
|
+
"""A WebSocket is recorded as its handshake (GET, 101); frames are only counted."""
|
|
151
|
+
if m == "Network.webSocketCreated":
|
|
152
|
+
url = p.get("url", "")
|
|
153
|
+
http_url = "https" + url[3:] if url.startswith("wss:") else "http" + url[2:] if url.startswith("ws:") else url
|
|
154
|
+
if self.in_scope(http_url):
|
|
155
|
+
rec = _Rec(key[1])
|
|
156
|
+
rec.req, rec.rtype = {"url": http_url, "method": "GET", "headers": {}}, "WebSocket"
|
|
157
|
+
self.sockets[key] = rec
|
|
158
|
+
elif key not in self.sockets:
|
|
159
|
+
return
|
|
160
|
+
elif m == "Network.webSocketWillSendHandshakeRequest":
|
|
161
|
+
self.sockets[key].req["headers"] = (p.get("request") or {}).get("headers") or {}
|
|
162
|
+
elif m == "Network.webSocketHandshakeResponseReceived":
|
|
163
|
+
rec = self.sockets[key]
|
|
164
|
+
rec.resp = p.get("response") or {}
|
|
165
|
+
rec.extra_req = rec.resp.get("requestHeaders") or None
|
|
166
|
+
self.done.append(rec)
|
|
167
|
+
elif m in ("Network.webSocketFrameSent", "Network.webSocketFrameReceived"):
|
|
168
|
+
self.frames += 1
|
|
169
|
+
elif m == "Network.webSocketClosed":
|
|
170
|
+
self.sockets.pop(key, None)
|
|
171
|
+
|
|
172
|
+
def _body(self, sid: str, rec: _Rec) -> None:
|
|
173
|
+
r = self._call("Network.getResponseBody", {"requestId": rec.id}, sid, 15)
|
|
174
|
+
body = r.get("body", "")
|
|
175
|
+
try:
|
|
176
|
+
rec.body = (base64.b64decode(body) if r.get("base64Encoded") else body.encode("utf-8", "replace"))
|
|
177
|
+
except ValueError:
|
|
178
|
+
rec.body = b""
|
|
179
|
+
rec.body = rec.body[: self.max_body]
|
|
180
|
+
if rec.req.get("hasPostData") and rec.post is None:
|
|
181
|
+
rec.post = self._call("Network.getRequestPostData", {"requestId": rec.id}, sid, 5).get("postData")
|