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
proxy2model/core/diff.py
ADDED
|
@@ -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)
|