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,330 @@
1
+ """What changed between two builds of the same app, grouped so a release reads as a handful of lines.
2
+
3
+ The comparison is by deterministic id: a node is the same node in both builds if its id is the same, and an
4
+ edge is the same if its source, type and target are the same. From that:
5
+
6
+ NEW in the newer build only
7
+ CHANGED in both, but its fingerprint moved (a status, a server header, a cookie flag, a version)
8
+ GONE in the older build only
9
+
10
+ Additions that connect to each other collapse into one workflow: a new page, the script it loads, the
11
+ endpoints that script names and their parameters are one line, not a dozen. Everything is drift to
12
+ understand, never a finding, and each line cites the requests that show it (from the newer build for new
13
+ and changed things, from the older one for things that went).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from .model import Model, match_key
19
+
20
+ _ORDER = {"host": 0, "route": 1, "script": 2, "endpoint": 3, "operation": 4, "parameter": 5, "third_party": 6,
21
+ "auth": 7, "cookie": 8, "role": 9, "tech": 10}
22
+ _HEAD = ("host", "route", "endpoint", "script", "third_party", "tech", "cookie", "auth", "role", "operation", "parameter")
23
+
24
+
25
+ def _fp(n) -> dict:
26
+ """The facts about a node that can move without it being a different node."""
27
+ a = n.attrs
28
+ out: dict = {}
29
+ if n.type == "endpoint":
30
+ out["state"] = a.get("api_state")
31
+ out["statuses"] = sorted(a.get("statuses") or [])
32
+ out["by role"] = {r: sorted(v) for r, v in sorted((a.get("status_by_role") or {}).items())}
33
+ out["credentials"] = sorted(a.get("credentials") or [])
34
+ out["roles"] = sorted(n.roles)
35
+ elif n.type == "route":
36
+ out["statuses"] = sorted(a.get("statuses") or [])
37
+ out["roles"] = sorted(n.roles)
38
+ elif n.type == "host":
39
+ out["header"] = {k: v for k, v in sorted((a.get("tech") or {}).items())}
40
+ out["schemes"] = sorted(a.get("schemes") or [])
41
+ out["ports"] = sorted(a.get("ports") or [])
42
+ elif n.type == "cookie":
43
+ out["HttpOnly"] = a.get("httponly")
44
+ out["Secure"] = a.get("secure")
45
+ out["SameSite"] = a.get("samesite")
46
+ elif n.type == "script":
47
+ sm = a.get("sourcemap") or {}
48
+ out["source map captured"] = sm.get("captured") if sm else None
49
+ out["worker kind"] = a.get("worker")
50
+ return out
51
+
52
+
53
+ def _show(v) -> str:
54
+ if isinstance(v, list):
55
+ return ", ".join(str(x) for x in v) or "none"
56
+ if isinstance(v, dict):
57
+ return "; ".join(f"{k}: {_show(x)}" for k, x in v.items()) or "none"
58
+ return "none" if v is None else str(v)
59
+
60
+
61
+ def _summarise(field: str, a, b) -> str:
62
+ if isinstance(a, list) and isinstance(b, list):
63
+ gone, new = [x for x in a if x not in b], [x for x in b if x not in a]
64
+ if len(gone) == 1 and len(new) == 1:
65
+ return f"{field} {gone[0]} → {new[0]}"
66
+ return f"{field} " + " ".join([f"+{x}" for x in new] + [f"−{x}" for x in gone])
67
+ if isinstance(a, dict) and isinstance(b, dict):
68
+ parts = []
69
+ for k in sorted(set(a) | set(b)):
70
+ if a.get(k) != b.get(k):
71
+ parts.append(_summarise(f"{field} {k}:", a.get(k), b.get(k)) if isinstance(a.get(k), list)
72
+ and isinstance(b.get(k), list) else f"{field} {k}: {_show(a.get(k))} → {_show(b.get(k))}")
73
+ return "; ".join(parts)
74
+ return f"{field} {_show(a)} → {_show(b)}"
75
+
76
+
77
+ def _changes(a, b) -> list[dict]:
78
+ fa, fb = _fp(a), _fp(b)
79
+ out = []
80
+ for k in fa:
81
+ if fa[k] != fb.get(k):
82
+ out.append({"field": k, "from": fa[k], "to": fb.get(k), "summary": _summarise(k, fa[k], fb.get(k))})
83
+ return out
84
+
85
+
86
+ def _stack_index(m: Model) -> dict:
87
+ return {(t["category"], t["name"]): t for t in getattr(m, "stack", [])}
88
+
89
+
90
+ def _components(ids: set[str], edges) -> list[list[str]]:
91
+ parent = {i: i for i in ids}
92
+
93
+ def find(x):
94
+ while parent[x] != x:
95
+ parent[x] = parent[parent[x]]
96
+ x = parent[x]
97
+ return x
98
+
99
+ for e in edges:
100
+ if e.src in ids and e.dst in ids:
101
+ parent[find(e.src)] = find(e.dst)
102
+ groups: dict[str, list[str]] = {}
103
+ for i in sorted(ids):
104
+ groups.setdefault(find(i), []).append(i)
105
+ return list(groups.values())
106
+
107
+
108
+ def _label(m: Model, nid: str) -> str:
109
+ n = m.nodes.get(nid)
110
+ return n.label if n else nid.split(":", 1)[-1]
111
+
112
+
113
+ def _incident(edges) -> dict[str, list[int]]:
114
+ """Positions in `edges` of the edges touching each node."""
115
+ at: dict[str, list[int]] = {}
116
+ for i, e in enumerate(edges):
117
+ at.setdefault(e.src, []).append(i)
118
+ if e.dst != e.src:
119
+ at.setdefault(e.dst, []).append(i)
120
+ return at
121
+
122
+
123
+ def _workflow(kind: str, m: Model, ids: list[str], edges, other: Model, hist=None, incident=None) -> dict:
124
+ nodes = sorted((m.nodes[i] for i in ids), key=lambda n: (_ORDER.get(n.type, 99), n.label))
125
+ head = next((n for t in _HEAD for n in nodes if n.type == t), nodes[0])
126
+ counts: dict[str, int] = {}
127
+ for n in nodes:
128
+ counts[n.type] = counts.get(n.type, 0) + 1
129
+ rest = [f"{c} {t.replace('_', ' ')}{'s' if c != 1 else ''}" for t, c in
130
+ sorted(((t, c) for t, c in counts.items() if t != head.type or c > 1), key=lambda x: _ORDER.get(x[0], 99))]
131
+ links = []
132
+ # only the edges touching this workflow, in list order (scanning every edge per workflow was n^2)
133
+ incident = _incident(edges) if incident is None else incident
134
+ members = set(ids)
135
+ for e in (edges[i] for i in sorted({i for nid in ids for i in incident.get(nid, ())})):
136
+ a_in, b_in = e.src in members, e.dst in members
137
+ if a_in != b_in:
138
+ outside = e.dst if a_in else e.src
139
+ if (outside in other.nodes or outside in m.nodes) and \
140
+ getattr(m.nodes.get(outside) or other.nodes.get(outside), "type", "") not in ("host", "tech"):
141
+ links.append({"from" if b_in else "to": _label(m, outside), "edge": e.type.lower(),
142
+ "node": _label(m, e.dst if b_in else e.src)})
143
+ ev = sorted({i for n in nodes for i in n.evidence})[:8]
144
+ return {"kind": kind, "title": f"{head.type.replace('_', ' ')} {head.label}", "head": head.label,
145
+ "summary": ", ".join(rest), "nodes": [{"id": n.id, "type": n.type, "label": n.label} for n in nodes],
146
+ "chain": [n.label for n in nodes if n.type != "parameter"][:8]
147
+ + ([f"+{sum(1 for n in nodes if n.type == 'parameter')} parameters"]
148
+ if any(n.type == "parameter" for n in nodes) else []),
149
+ "links": links[:3], "evidence": ev}
150
+
151
+
152
+ def diff_models(old: Model, new: Model, old_meta: dict | None = None, new_meta: dict | None = None,
153
+ hist=None) -> dict:
154
+ """Compare two models. `hist(kind, key)` may return {'first_seen','last_seen'} for the history lines."""
155
+ on, nn = old.nodes, new.nodes
156
+ # technology markers are covered by the host's headers and the stack below, so they are not repeated
157
+ new_ids = {i for i in set(nn) - set(on) if nn[i].type != "tech"}
158
+ gone_ids = {i for i in set(on) - set(nn) if on[i].type != "tech"}
159
+
160
+ # a path the code named without a method that traffic then walked was not retired: a gap closed
161
+ closed: dict[str, str] = {}
162
+ walked = {(n.attrs.get("host"), match_key(n.attrs.get("path", ""))): n.id for n in nn.values()
163
+ if n.type == "endpoint" and n.attrs.get("method_known", True)}
164
+ for gid in sorted(gone_ids):
165
+ n = on[gid]
166
+ if n.type == "endpoint" and not n.attrs.get("method_known", True):
167
+ hit = walked.get((n.attrs.get("host"), match_key(n.attrs.get("path", ""))))
168
+ if hit and hit in new_ids:
169
+ closed[gid] = hit
170
+ for g, h in closed.items():
171
+ gone_ids.discard(g)
172
+ new_ids.discard(h)
173
+
174
+ changed = []
175
+ for nid in sorted(set(on) & set(nn)):
176
+ ch = _changes(on[nid], nn[nid])
177
+ if ch:
178
+ changed.append({"kind": nn[nid].type, "id": nid, "label": nn[nid].label, "changes": ch,
179
+ "evidence": sorted(nn[nid].evidence)[:3]})
180
+ for g, h in sorted(closed.items()):
181
+ changed.append({"kind": "endpoint", "id": h, "label": nn[h].label,
182
+ "changes": [{"field": "state", "from": "STATIC_ONLY", "to": nn[h].attrs.get("api_state"),
183
+ "summary": f"named in code before, now walked in traffic ({on[g].label} → {nn[h].label})"}],
184
+ "evidence": sorted(nn[h].evidence)[:3]})
185
+
186
+ # the technology stack, compared by (category, name)
187
+ so, sn = _stack_index(old), _stack_index(new)
188
+ stack_new = [{"kind": "stack", "label": f"{k[1]}{' ' + v['version'] if v.get('version') else ''}", "category": k[0],
189
+ "evidence": v.get("evidence", [])[:3]} for k, v in sorted(sn.items()) if k not in so]
190
+ stack_gone = [{"kind": "stack", "label": f"{k[1]}{' ' + v['version'] if v.get('version') else ''}", "category": k[0]}
191
+ for k, v in sorted(so.items()) if k not in sn]
192
+ for k in sorted(set(so) & set(sn)):
193
+ if (so[k].get("version") or "") != (sn[k].get("version") or ""):
194
+ changed.append({"kind": "stack", "id": f"stack:{k[0]}:{k[1]}", "label": k[1], "category": k[0],
195
+ "changes": [{"field": "version", "from": so[k].get("version"), "to": sn[k].get("version"),
196
+ "summary": f"version {so[k].get('version') or 'none'} → {sn[k].get('version') or 'none'}"}],
197
+ "evidence": sn[k].get("evidence", [])[:3]})
198
+ # a server swap reads as one thing: edge/web-server entries going and coming in the same category
199
+ for cat in ("Edge & CDN", "Web server", "Language & framework"):
200
+ g = [x for x in stack_gone if x["category"] == cat]
201
+ a = [x for x in stack_new if x["category"] == cat]
202
+ if len(g) == 1 and len(a) == 1:
203
+ changed.append({"kind": "stack", "id": f"stack:{cat}", "label": cat, "category": cat,
204
+ "changes": [{"field": "technology", "from": g[0]["label"], "to": a[0]["label"],
205
+ "summary": f"{cat.lower()} {g[0]['label']} → {a[0]['label']}"}],
206
+ "evidence": a[0].get("evidence", [])})
207
+ stack_gone.remove(g[0])
208
+ stack_new.remove(a[0])
209
+
210
+ ekey = lambda e: (e.src, e.type, e.dst) # noqa: E731
211
+ old_e, new_e = {ekey(e): e for e in old.edges}, {ekey(e): e for e in new.edges}
212
+ added_e = [new_e[k] for k in sorted(set(new_e) - set(old_e))]
213
+ removed_e = [old_e[k] for k in sorted(set(old_e) - set(new_e))]
214
+
215
+ def workflows(kind, model, ids, edges, other):
216
+ wf, singles = [], []
217
+ incident = _incident(edges)
218
+ for comp in _components(set(ids), edges):
219
+ if len(comp) >= 2:
220
+ wf.append(_workflow(kind, model, comp, edges, other, incident=incident))
221
+ else:
222
+ singles.append(comp[0])
223
+ wf.sort(key=lambda w: (-len(w["nodes"]), w["title"]))
224
+ return wf, singles
225
+
226
+ new_wf, new_single = workflows("new_workflow", new, new_ids, added_e, old)
227
+ gone_wf, gone_single = workflows("gone_workflow", old, gone_ids, removed_e, new)
228
+ in_wf_new = {n["id"] for w in new_wf for n in w["nodes"]} | set(new_single)
229
+ in_wf_gone = {n["id"] for w in gone_wf for n in w["nodes"]} | set(gone_single)
230
+
231
+ def single(model, nid, side):
232
+ n = model.nodes[nid]
233
+ d = {"kind": n.type, "label": n.label, "state": n.attrs.get("api_state"),
234
+ "evidence": sorted(n.evidence)[:3]}
235
+ if hist:
236
+ h = hist("node", nid)
237
+ if h:
238
+ d["first_seen" if side == "new" else "last_seen"] = h["first_seen" if side == "new" else "last_seen"]
239
+ return d
240
+
241
+ def loose_edges(edges, model, touched):
242
+ out = []
243
+ for e in edges:
244
+ if e.src in touched or e.dst in touched:
245
+ continue # already part of a workflow or a new/gone node
246
+ if any(getattr(model.nodes.get(x), "type", "") == "tech" for x in (e.src, e.dst)):
247
+ continue # a technology marker: the host's headers and the stack say it
248
+ out.append({"from": _label(model, e.src), "edge": e.type.lower(), "to": _label(model, e.dst),
249
+ "state": e.state, "evidence": sorted(e.evidence)[:3]})
250
+ return out
251
+
252
+ new_edges = loose_edges(added_e, new, in_wf_new)
253
+ gone_edges = loose_edges(removed_e, old, in_wf_gone)
254
+ summary = {"new": len(new_single) + len(stack_new) + len(new_edges),
255
+ "changed": len(changed),
256
+ "gone": len(gone_single) + len(stack_gone) + len(gone_edges),
257
+ "new_workflows": len(new_wf), "gone_workflows": len(gone_wf)}
258
+ summary["total"] = sum(summary[k] for k in ("new", "changed", "gone", "new_workflows", "gone_workflows"))
259
+ changed.sort(key=lambda c: (_ORDER.get(c["kind"], 99), c["label"]))
260
+ return {
261
+ "app": new.name,
262
+ "from": old_meta or {}, "to": new_meta or {},
263
+ "same": summary["total"] == 0,
264
+ "summary": summary,
265
+ "workflows": new_wf + gone_wf,
266
+ "new": {"nodes": sorted((single(new, i, "new") for i in new_single), key=lambda d: (_ORDER.get(d["kind"], 99), d["label"])),
267
+ "stack": stack_new, "edges": new_edges},
268
+ "changed": changed,
269
+ "gone": {"nodes": sorted((single(old, i, "gone") for i in gone_single), key=lambda d: (_ORDER.get(d["kind"], 99), d["label"])),
270
+ "stack": stack_gone, "edges": gone_edges},
271
+ "note": "Drift between two builds. Each line is a change in the attack surface to understand, never a finding. "
272
+ "Evidence ids are from the newer build for new and changed things, the older build for gone ones.",
273
+ }
274
+
275
+
276
+ def _ev(ids) -> str:
277
+ return (" " + ", ".join(f"ev_{i}" for i in ids)) if ids else ""
278
+
279
+
280
+ def format_text(d: dict) -> str:
281
+ """The grouped change report as plain text."""
282
+ f, t, s = d.get("from") or {}, d.get("to") or {}, d["summary"]
283
+ tag = lambda m: (f"build {m.get('id')}" + (f" ({m['built_at'][:10]})" if m.get("built_at") else "")) if m else "?" # noqa: E731
284
+ out = [f"{d['app']}: {tag(f)} → {tag(t)}"]
285
+ if d["same"]:
286
+ out.append("No drift between these two builds.")
287
+ return "\n".join(out)
288
+ out.append(f" {s['new_workflows']} new workflow(s) · {s['new']} new · {s['changed']} changed · "
289
+ f"{s['gone']} gone" + (f" · {s['gone_workflows']} removed workflow(s)" if s["gone_workflows"] else ""))
290
+ wf_new = [w for w in d["workflows"] if w["kind"] == "new_workflow"]
291
+ wf_gone = [w for w in d["workflows"] if w["kind"] == "gone_workflow"]
292
+ if wf_new:
293
+ out += ["", "NEW WORKFLOWS (connected additions, one line each)"]
294
+ for w in wf_new:
295
+ out.append(f" ▸ {w['title']} ({len(w['nodes'])} things: {w['summary']})")
296
+ out.append(" " + " → ".join(w["chain"]))
297
+ for l in w["links"]:
298
+ out.append(f" {l.get('node')} {l['edge']} {l.get('from') or l.get('to')}")
299
+ if w["evidence"]:
300
+ out.append(" " + ", ".join(f"ev_{i}" for i in w["evidence"]))
301
+ if d["changed"]:
302
+ out += ["", "CHANGED"]
303
+ for c in d["changed"]:
304
+ out.append(f" ~ {c['label']} [{c['kind']}]")
305
+ for x in c["changes"]:
306
+ out.append(f" {x['summary']}")
307
+ if c["evidence"]:
308
+ out.append(" " + ", ".join(f"ev_{i}" for i in c["evidence"]))
309
+ n = d["new"]
310
+ if n["nodes"] or n["stack"] or n["edges"]:
311
+ out += ["", "NEW"]
312
+ for x in n["nodes"]:
313
+ out.append(f" + {x['label']} [{x['kind']}]{_ev(x['evidence'])}")
314
+ for x in n["stack"]:
315
+ out.append(f" + {x['label']} [{x['category']}]{_ev(x['evidence'])}")
316
+ for x in n["edges"]:
317
+ out.append(f" + {x['from']} {x['edge']} {x['to']}{_ev(x['evidence'])}")
318
+ g = d["gone"]
319
+ if g["nodes"] or g["stack"] or g["edges"] or wf_gone:
320
+ out += ["", "GONE (evidence is from the older build)"]
321
+ for w in wf_gone:
322
+ out.append(f" - {w['title']} ({len(w['nodes'])} things: {w['summary']})")
323
+ for x in g["nodes"]:
324
+ seen = f" last seen {x['last_seen'][:10]}" if x.get("last_seen") else ""
325
+ out.append(f" - {x['label']} [{x['kind']}]{seen}")
326
+ for x in g["stack"]:
327
+ out.append(f" - {x['label']} [{x['category']}]")
328
+ for x in g["edges"]:
329
+ out.append(f" - {x['from']} {x['edge']} {x['to']}")
330
+ return "\n".join(out)
@@ -0,0 +1,98 @@
1
+ """
2
+ Registrable domains from the Public Suffix List.
3
+
4
+ `api.shop.bank.in` belongs to `shop.bank.in`, `www.example.co.in` to
5
+ `example.co.in`, and `user.github.io` is its own site, not part of
6
+ `github.io`. A "last two labels" rule gets all three wrong; the Public Suffix
7
+ List gets them right.
8
+
9
+ A snapshot of the list ships inside the package (data/public_suffix_list.dat),
10
+ so resolving a domain never touches the network. To use a newer list, point
11
+ PROXY2MODEL_PSL at a local copy of https://publicsuffix.org/list/public_suffix_list.dat.
12
+
13
+ The algorithm is the one publicsuffix.org specifies: the longest matching rule
14
+ wins, `*` matches one label, and `!` exception rules beat wildcards.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import re
21
+ from functools import lru_cache
22
+
23
+ _BUNDLED = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "public_suffix_list.dat")
24
+ _IPV4_RE = re.compile(r"^\d{1,3}(\.\d{1,3}){3}$")
25
+
26
+
27
+ def _to_ascii(label: str) -> str:
28
+ try:
29
+ return label.encode("idna").decode("ascii")
30
+ except UnicodeError:
31
+ return label
32
+
33
+
34
+ @lru_cache(maxsize=4)
35
+ def _load(path: str) -> tuple[frozenset, frozenset, frozenset]:
36
+ rules, wildcards, exceptions = set(), set(), set()
37
+ with open(path, encoding="utf-8") as f:
38
+ for line in f:
39
+ line = line.strip()
40
+ if not line or line.startswith("//"):
41
+ continue
42
+ rule = line.split()[0].lower()
43
+ if rule.startswith("!"):
44
+ exceptions.add(".".join(_to_ascii(p) for p in rule[1:].split(".")))
45
+ elif rule.startswith("*."):
46
+ wildcards.add(".".join(_to_ascii(p) for p in rule[2:].split(".")))
47
+ else:
48
+ rules.add(".".join(_to_ascii(p) for p in rule.split(".")))
49
+ return frozenset(rules), frozenset(wildcards), frozenset(exceptions)
50
+
51
+
52
+ def _rules():
53
+ return _load(os.environ.get("PROXY2MODEL_PSL") or _BUNDLED)
54
+
55
+
56
+ def _normalize(host: str) -> str:
57
+ h = host.strip().lower().rstrip(".")
58
+ if h.startswith("[") or h.count(":") > 1: # IPv6 literal
59
+ return h
60
+ h = h.split(":")[0] # drop a port
61
+ return ".".join(_to_ascii(p) for p in h.split(".") if p)
62
+
63
+
64
+ def public_suffix(host: str) -> str:
65
+ """The public suffix of `host` (`bank.in` for `www.shop.bank.in`)."""
66
+ h = _normalize(host)
67
+ labels = h.split(".")
68
+ rules, wildcards, exceptions = _rules()
69
+ for i in range(len(labels)):
70
+ cand = ".".join(labels[i:])
71
+ parent = ".".join(labels[i + 1:])
72
+ if cand in exceptions:
73
+ return parent # !www.ck → suffix is ck
74
+ if cand in rules:
75
+ return cand
76
+ if parent and parent in wildcards:
77
+ return cand # *.ck matches foo.ck
78
+ return labels[-1] # unlisted TLD: implicit "*" rule
79
+
80
+
81
+ def registrable_domain(host: str) -> str:
82
+ """The domain a registrant controls: public suffix + one label.
83
+
84
+ IP addresses, IPv6 literals and single-label hosts come back unchanged;
85
+ a host that *is* a public suffix comes back as itself.
86
+ """
87
+ h = _normalize(host)
88
+ if not h or _IPV4_RE.match(h) or h.startswith("[") or ":" in h or "." not in h:
89
+ return h
90
+ suffix = public_suffix(h)
91
+ if h == suffix:
92
+ return h
93
+ head = h[: -len(suffix) - 1]
94
+ return head.split(".")[-1] + "." + suffix
95
+
96
+
97
+ def same_site(a: str, b: str) -> bool:
98
+ return registrable_domain(a) == registrable_domain(b)