proxy2model 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. proxy2model/__init__.py +19 -0
  2. proxy2model/__main__.py +6 -0
  3. proxy2model/atomic.py +68 -0
  4. proxy2model/cli/__init__.py +3 -0
  5. proxy2model/cli/analyze.py +501 -0
  6. proxy2model/cli/app.py +408 -0
  7. proxy2model/cli/browse.py +279 -0
  8. proxy2model/cli/build.py +399 -0
  9. proxy2model/cli/common.py +190 -0
  10. proxy2model/cli/history.py +224 -0
  11. proxy2model/cli/mcp.py +184 -0
  12. proxy2model/cli/ui.py +62 -0
  13. proxy2model/core/__init__.py +1 -0
  14. proxy2model/core/changes.py +124 -0
  15. proxy2model/core/context.py +571 -0
  16. proxy2model/core/diff.py +330 -0
  17. proxy2model/core/domains.py +98 -0
  18. proxy2model/core/graph.py +562 -0
  19. proxy2model/core/methodology.py +343 -0
  20. proxy2model/core/model.py +998 -0
  21. proxy2model/core/stack.py +218 -0
  22. proxy2model/data/fonts/LICENSES.md +16 -0
  23. proxy2model/data/fonts/inter-latin-wght-normal.woff2 +0 -0
  24. proxy2model/data/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  25. proxy2model/data/fonts/nippo-200.woff2 +0 -0
  26. proxy2model/data/fonts/nippo-300.woff2 +0 -0
  27. proxy2model/data/fonts/nippo-400.woff2 +0 -0
  28. proxy2model/data/fonts/nippo-500.woff2 +0 -0
  29. proxy2model/data/fonts/nippo-700.woff2 +0 -0
  30. proxy2model/data/public_suffix_list.dat +16497 -0
  31. proxy2model/db/__init__.py +1 -0
  32. proxy2model/db/bql.py +710 -0
  33. proxy2model/db/bql_js.py +278 -0
  34. proxy2model/db/store.py +547 -0
  35. proxy2model/export/__init__.py +1 -0
  36. proxy2model/export/openapi.py +248 -0
  37. proxy2model/ingest/__init__.py +1 -0
  38. proxy2model/ingest/browse.py +181 -0
  39. proxy2model/ingest/browser.py +1058 -0
  40. proxy2model/ingest/crawl.py +673 -0
  41. proxy2model/ingest/extract.py +208 -0
  42. proxy2model/ingest/infer.py +75 -0
  43. proxy2model/ingest/journey.py +148 -0
  44. proxy2model/ingest/live.py +96 -0
  45. proxy2model/ingest/parse.py +1275 -0
  46. proxy2model/ingest/redact.py +535 -0
  47. proxy2model/py.typed +0 -0
  48. proxy2model/recon/__init__.py +1 -0
  49. proxy2model/recon/osint.py +607 -0
  50. proxy2model/report/__init__.py +1 -0
  51. proxy2model/report/animate.py +163 -0
  52. proxy2model/report/dash.py +469 -0
  53. proxy2model/report/fonts.py +51 -0
  54. proxy2model/report/html.py +988 -0
  55. proxy2model/report/inventory.py +258 -0
  56. proxy2model/report/map.py +515 -0
  57. proxy2model/report/pages.py +537 -0
  58. proxy2model/report/query.py +306 -0
  59. proxy2model/report/shot.py +55 -0
  60. proxy2model/report/view.py +213 -0
  61. proxy2model-2.0.0.dist-info/METADATA +148 -0
  62. proxy2model-2.0.0.dist-info/RECORD +66 -0
  63. proxy2model-2.0.0.dist-info/WHEEL +5 -0
  64. proxy2model-2.0.0.dist-info/entry_points.txt +2 -0
  65. proxy2model-2.0.0.dist-info/licenses/LICENSE +201 -0
  66. proxy2model-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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")