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,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Diff two models of the same app, grouped into feature areas.
|
|
3
|
+
|
|
4
|
+
Recon is not a one-shot: an app changes, and the interesting question at each
|
|
5
|
+
release is *what moved*. This compares an older model to the current one and
|
|
6
|
+
reports the drift — what appeared, what is gone — grouped by feature area and
|
|
7
|
+
annotated with why a researcher would care.
|
|
8
|
+
|
|
9
|
+
It never says "this is a bug". A new admin endpoint or a new third party is a
|
|
10
|
+
change in the attack surface worth a look, stated as such and cited to the
|
|
11
|
+
evidence in the current model.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .graph import ReasonGraph
|
|
17
|
+
from .model import PRIV_RE, Model, match_key
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _by_type(m: Model, t: str) -> dict[str, object]:
|
|
21
|
+
return {nid: n for nid, n in m.nodes.items() if n.type == t}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _why_appeared(kind: str, node) -> str | None:
|
|
25
|
+
path = node.attrs.get("path", "") or ""
|
|
26
|
+
if kind == "endpoint":
|
|
27
|
+
if PRIV_RE.search(path):
|
|
28
|
+
return "a new privileged-looking endpoint — confirm who may reach it"
|
|
29
|
+
if node.attrs.get("api_state") == "STATIC_ONLY":
|
|
30
|
+
return "new, and only referenced in code — an unwalked path to try"
|
|
31
|
+
return "a new endpoint in the surface"
|
|
32
|
+
if kind == "third_party":
|
|
33
|
+
return "new third-party code the app now talks to — trust left the app"
|
|
34
|
+
if kind == "host":
|
|
35
|
+
return "a new first-party host in scope"
|
|
36
|
+
if kind == "tech":
|
|
37
|
+
return "a newly advertised technology marker"
|
|
38
|
+
if kind == "cookie":
|
|
39
|
+
return "a new cookie — check its flags and what it carries"
|
|
40
|
+
if kind == "auth":
|
|
41
|
+
return "a new credential scheme on the surface"
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _why_gone(kind: str) -> str | None:
|
|
46
|
+
if kind == "endpoint":
|
|
47
|
+
return "an endpoint no longer seen — retired, renamed, or just not walked this time"
|
|
48
|
+
if kind == "third_party":
|
|
49
|
+
return "a third party the app no longer talks to"
|
|
50
|
+
if kind == "host":
|
|
51
|
+
return "a host no longer in the capture"
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def diff_models(old: Model, new: Model) -> dict:
|
|
56
|
+
"""What moved between two models of the same app. Drift, never findings."""
|
|
57
|
+
rg = ReasonGraph(new)
|
|
58
|
+
area_of = {}
|
|
59
|
+
for c in rg.communities():
|
|
60
|
+
for lbl in c["members"]:
|
|
61
|
+
area_of[lbl] = c["name"]
|
|
62
|
+
|
|
63
|
+
KINDS = ("endpoint", "route", "script", "host", "third_party", "cookie", "auth", "tech")
|
|
64
|
+
areas: dict[str, dict] = {}
|
|
65
|
+
totals = {"appeared": 0, "gone": 0}
|
|
66
|
+
|
|
67
|
+
def area(name):
|
|
68
|
+
return areas.setdefault(name, {"area": name, "appeared": [], "gone": []})
|
|
69
|
+
|
|
70
|
+
def _keys(m: Model, method_known: bool) -> set:
|
|
71
|
+
return {(n.attrs.get("host"), match_key(n.attrs.get("path", "")))
|
|
72
|
+
for n in _by_type(m, "endpoint").values()
|
|
73
|
+
if bool(n.attrs.get("method_known", True)) == method_known}
|
|
74
|
+
|
|
75
|
+
# a path named in code with no method (`* /api/x`) that a later capture
|
|
76
|
+
# actually walked (`GET /api/x`) was not retired — it was resolved
|
|
77
|
+
old_unwalked = _keys(old, False)
|
|
78
|
+
now_walked = _keys(new, True)
|
|
79
|
+
|
|
80
|
+
for kind in KINDS:
|
|
81
|
+
old_ids = set(_by_type(old, kind))
|
|
82
|
+
new_nodes = _by_type(new, kind)
|
|
83
|
+
new_ids = set(new_nodes)
|
|
84
|
+
old_nodes = _by_type(old, kind)
|
|
85
|
+
for nid in sorted(new_ids - old_ids):
|
|
86
|
+
n = new_nodes[nid]
|
|
87
|
+
why = _why_appeared(kind, n)
|
|
88
|
+
if kind == "endpoint" and (n.attrs.get("host"), match_key(n.attrs.get("path", ""))) \
|
|
89
|
+
in old_unwalked and n.attrs.get("method_known", True):
|
|
90
|
+
why = "named in code before, now walked in traffic — a gap closed"
|
|
91
|
+
if why is None:
|
|
92
|
+
continue
|
|
93
|
+
area(area_of.get(n.label, kind)) ["appeared"].append({
|
|
94
|
+
"kind": kind, "label": n.label, "why": why,
|
|
95
|
+
"state": n.attrs.get("api_state"),
|
|
96
|
+
"evidence": list(n.evidence[:3])})
|
|
97
|
+
totals["appeared"] += 1
|
|
98
|
+
for nid in sorted(old_ids - new_ids):
|
|
99
|
+
n = old_nodes[nid]
|
|
100
|
+
why = _why_gone(kind)
|
|
101
|
+
if kind == "endpoint" and not n.attrs.get("method_known", True) and \
|
|
102
|
+
(n.attrs.get("host"), match_key(n.attrs.get("path", ""))) in now_walked:
|
|
103
|
+
continue # resolved by traffic, reported above as a closed gap
|
|
104
|
+
if why is None:
|
|
105
|
+
continue
|
|
106
|
+
area(area_of.get(n.label, kind))["gone"].append({
|
|
107
|
+
"kind": kind, "label": n.label, "why": why})
|
|
108
|
+
totals["gone"] += 1
|
|
109
|
+
|
|
110
|
+
lines = []
|
|
111
|
+
for a in sorted(areas.values(),
|
|
112
|
+
key=lambda a: (-(len(a["appeared"]) + len(a["gone"])), a["area"])):
|
|
113
|
+
if a["appeared"] or a["gone"]:
|
|
114
|
+
a["appeared"].sort(key=lambda x: (x["kind"], x["label"]))
|
|
115
|
+
a["gone"].sort(key=lambda x: (x["kind"], x["label"]))
|
|
116
|
+
lines.append(a)
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
"app": new.name,
|
|
120
|
+
"totals": totals,
|
|
121
|
+
"note": "Drift between two captures. An appearance is a change in the "
|
|
122
|
+
"attack surface to understand, never a finding.",
|
|
123
|
+
"areas": lines,
|
|
124
|
+
}
|