pypproxy 0.1.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.
- pypproxy/__init__.py +0 -0
- pypproxy/api/__init__.py +0 -0
- pypproxy/api/server.py +427 -0
- pypproxy/bulk/__init__.py +0 -0
- pypproxy/bulk/sender.py +97 -0
- pypproxy/cert/__init__.py +0 -0
- pypproxy/cert/ca.py +144 -0
- pypproxy/cert/client_cert.py +65 -0
- pypproxy/codec.py +176 -0
- pypproxy/config/__init__.py +0 -0
- pypproxy/config/config.py +106 -0
- pypproxy/dns/__init__.py +0 -0
- pypproxy/dns/server.py +149 -0
- pypproxy/exporter/__init__.py +0 -0
- pypproxy/exporter/exporter.py +122 -0
- pypproxy/exporter/importer.py +169 -0
- pypproxy/graphql/__init__.py +0 -0
- pypproxy/graphql/detector.py +76 -0
- pypproxy/graphql/introspection.py +217 -0
- pypproxy/graphql/modifier.py +98 -0
- pypproxy/graphql/schema_store.py +33 -0
- pypproxy/intercept/__init__.py +0 -0
- pypproxy/intercept/manager.py +142 -0
- pypproxy/interceptor/__init__.py +0 -0
- pypproxy/interceptor/interceptor.py +172 -0
- pypproxy/proto/__init__.py +0 -0
- pypproxy/proto/grpc.py +48 -0
- pypproxy/proto/mqtt.py +119 -0
- pypproxy/proto/ws.py +120 -0
- pypproxy/proto/ws_intercept.py +117 -0
- pypproxy/proxy/__init__.py +0 -0
- pypproxy/proxy/proxy.py +407 -0
- pypproxy/replay/__init__.py +0 -0
- pypproxy/replay/replay.py +77 -0
- pypproxy/rule/__init__.py +0 -0
- pypproxy/rule/rule.py +198 -0
- pypproxy/scan/__init__.py +0 -0
- pypproxy/scan/scanner.py +296 -0
- pypproxy/script/__init__.py +0 -0
- pypproxy/script/engine.py +49 -0
- pypproxy/security/__init__.py +0 -0
- pypproxy/security/header_checker.py +308 -0
- pypproxy/security/int_overflow.py +193 -0
- pypproxy/security/jwt_checker.py +273 -0
- pypproxy/security/plugin.py +152 -0
- pypproxy/security/randomness.py +165 -0
- pypproxy/store/__init__.py +0 -0
- pypproxy/store/db.py +189 -0
- pypproxy/store/filter_parser.py +181 -0
- pypproxy/store/fts.py +105 -0
- pypproxy/store/models.py +81 -0
- pypproxy/store/scope.py +63 -0
- pypproxy/store/store.py +120 -0
- pypproxy/ui/__init__.py +0 -0
- pypproxy/ui/app.py +386 -0
- pypproxy/ui/bulk_sender_ui.py +125 -0
- pypproxy/ui/cui.py +162 -0
- pypproxy/ui/detail.py +179 -0
- pypproxy/ui/diff_view.py +118 -0
- pypproxy/ui/graphql_tab.py +265 -0
- pypproxy/ui/import_tab.py +136 -0
- pypproxy/ui/intercept_dialog.py +74 -0
- pypproxy/ui/resender.py +140 -0
- pypproxy/ui/scan_tab.py +98 -0
- pypproxy/ui/security_tab.py +356 -0
- pypproxy/ui/settings.py +413 -0
- pypproxy/ui/theme.py +59 -0
- pypproxy-0.1.0.dist-info/METADATA +19 -0
- pypproxy-0.1.0.dist-info/RECORD +72 -0
- pypproxy-0.1.0.dist-info/WHEEL +4 -0
- pypproxy-0.1.0.dist-info/entry_points.txt +2 -0
- pypproxy-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class JWTCheckResult:
|
|
13
|
+
vector: str
|
|
14
|
+
description: str
|
|
15
|
+
modified_token: str = ""
|
|
16
|
+
status_code: int = 0
|
|
17
|
+
response_body: bytes = b""
|
|
18
|
+
duration_ms: int = 0
|
|
19
|
+
suspicious: bool = False
|
|
20
|
+
note: str = ""
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> dict:
|
|
23
|
+
import base64 as b64
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
"vector": self.vector,
|
|
27
|
+
"description": self.description,
|
|
28
|
+
"modified_token": self.modified_token,
|
|
29
|
+
"status_code": self.status_code,
|
|
30
|
+
"response_body": b64.b64encode(self.response_body).decode()
|
|
31
|
+
if self.response_body
|
|
32
|
+
else "",
|
|
33
|
+
"duration_ms": self.duration_ms,
|
|
34
|
+
"suspicious": self.suspicious,
|
|
35
|
+
"note": self.note,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _b64url_decode(s: str) -> bytes:
|
|
40
|
+
s += "=" * (-len(s) % 4)
|
|
41
|
+
return base64.urlsafe_b64decode(s)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _b64url_encode(b: bytes) -> str:
|
|
45
|
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parse_jwt(token: str) -> tuple[dict, dict, str] | None:
|
|
49
|
+
parts = token.split(".")
|
|
50
|
+
if len(parts) != 3:
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
header = json.loads(_b64url_decode(parts[0]))
|
|
54
|
+
payload = json.loads(_b64url_decode(parts[1]))
|
|
55
|
+
return header, payload, parts[2]
|
|
56
|
+
except Exception:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _make_jwt(header: dict, payload: dict, signature: str = "") -> str:
|
|
61
|
+
h = _b64url_encode(json.dumps(header, separators=(",", ":")).encode())
|
|
62
|
+
p = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode())
|
|
63
|
+
return f"{h}.{p}.{signature}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def generate_test_tokens(token: str) -> list[tuple[str, str, str]]:
|
|
67
|
+
"""
|
|
68
|
+
Generate attack tokens from a valid JWT.
|
|
69
|
+
Returns list of (vector_name, description, modified_token).
|
|
70
|
+
"""
|
|
71
|
+
parsed = _parse_jwt(token)
|
|
72
|
+
if parsed is None:
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
header, payload, sig = parsed
|
|
76
|
+
results: list[tuple[str, str, str]] = []
|
|
77
|
+
|
|
78
|
+
# 1. None algorithm
|
|
79
|
+
h_none = dict(header)
|
|
80
|
+
h_none["alg"] = "none"
|
|
81
|
+
results.append(
|
|
82
|
+
("none_alg", "Algorithm set to 'none' (no signature)", _make_jwt(h_none, payload, ""))
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
for variant in ("None", "NONE", "nOnE"):
|
|
86
|
+
h_v = dict(header)
|
|
87
|
+
h_v["alg"] = variant
|
|
88
|
+
results.append(
|
|
89
|
+
(f"none_alg_{variant}", f"Algorithm set to '{variant}'", _make_jwt(h_v, payload, ""))
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# 2. alg confusion: RS256 → HS256 (sign with public key as HMAC secret)
|
|
93
|
+
if header.get("alg", "").startswith("RS"):
|
|
94
|
+
h_hs = dict(header)
|
|
95
|
+
h_hs["alg"] = "HS256"
|
|
96
|
+
unsigned = _make_jwt(h_hs, payload, "").rsplit(".", 1)[0]
|
|
97
|
+
fake_sig = _b64url_encode(
|
|
98
|
+
hmac.new(b"public-key-here", unsigned.encode(), hashlib.sha256).digest()
|
|
99
|
+
)
|
|
100
|
+
results.append(
|
|
101
|
+
(
|
|
102
|
+
"alg_confusion_rs_hs",
|
|
103
|
+
"RS256→HS256 algorithm confusion",
|
|
104
|
+
_make_jwt(h_hs, payload, fake_sig),
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# 3. Empty signature
|
|
109
|
+
results.append(("empty_sig", "Empty signature", _make_jwt(header, payload, "")))
|
|
110
|
+
|
|
111
|
+
# 4. Payload manipulation — remove exp
|
|
112
|
+
if "exp" in payload:
|
|
113
|
+
p_no_exp = dict(payload)
|
|
114
|
+
del p_no_exp["exp"]
|
|
115
|
+
results.append(("no_exp", "Removed 'exp' claim", _make_jwt(header, p_no_exp, sig)))
|
|
116
|
+
|
|
117
|
+
# 5. Payload manipulation — extend exp far future
|
|
118
|
+
if "exp" in payload:
|
|
119
|
+
p_ext = dict(payload)
|
|
120
|
+
p_ext["exp"] = 9999999999
|
|
121
|
+
results.append(
|
|
122
|
+
("extended_exp", "Extended 'exp' to year 2286", _make_jwt(header, p_ext, sig))
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# 6. Privilege escalation attempts
|
|
126
|
+
priv_fields = {
|
|
127
|
+
"role": ["admin", "superuser", "root"],
|
|
128
|
+
"is_admin": [True],
|
|
129
|
+
"admin": [True],
|
|
130
|
+
"scope": ["admin"],
|
|
131
|
+
}
|
|
132
|
+
for field_name, values in priv_fields.items():
|
|
133
|
+
if field_name in payload:
|
|
134
|
+
for val in values:
|
|
135
|
+
p_priv = dict(payload)
|
|
136
|
+
p_priv[field_name] = val
|
|
137
|
+
results.append(
|
|
138
|
+
(
|
|
139
|
+
f"priv_escalation_{field_name}",
|
|
140
|
+
f"Set {field_name}={val!r}",
|
|
141
|
+
_make_jwt(header, p_priv, sig),
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# 7. JKU header injection
|
|
146
|
+
h_jku = dict(header)
|
|
147
|
+
h_jku["jku"] = "http://attacker.example.com/jwks.json"
|
|
148
|
+
results.append(
|
|
149
|
+
(
|
|
150
|
+
"jku_injection",
|
|
151
|
+
"JKU header pointing to attacker-controlled URL",
|
|
152
|
+
_make_jwt(h_jku, payload, sig),
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# 8. KID injection
|
|
157
|
+
h_kid_sqli = dict(header)
|
|
158
|
+
h_kid_sqli["kid"] = "' OR 1=1--"
|
|
159
|
+
results.append(("kid_sqli", "KID SQL injection", _make_jwt(h_kid_sqli, payload, sig)))
|
|
160
|
+
|
|
161
|
+
h_kid_path = dict(header)
|
|
162
|
+
h_kid_path["kid"] = "../../dev/null"
|
|
163
|
+
results.append(
|
|
164
|
+
("kid_path_traversal", "KID path traversal", _make_jwt(h_kid_path, payload, sig))
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# 9. Original sig with modified payload (signature bypass)
|
|
168
|
+
p_modified = dict(payload)
|
|
169
|
+
p_modified["_test"] = "paxy"
|
|
170
|
+
results.append(
|
|
171
|
+
(
|
|
172
|
+
"sig_bypass",
|
|
173
|
+
"Modified payload with original signature",
|
|
174
|
+
_make_jwt(header, p_modified, sig),
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return results
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def run_checks(
|
|
182
|
+
token: str,
|
|
183
|
+
entry_id: int,
|
|
184
|
+
method: str,
|
|
185
|
+
scheme: str,
|
|
186
|
+
host: str,
|
|
187
|
+
path: str,
|
|
188
|
+
query: str,
|
|
189
|
+
headers: dict[str, list[str]],
|
|
190
|
+
body: bytes,
|
|
191
|
+
timeout: int = 30,
|
|
192
|
+
) -> list[JWTCheckResult]:
|
|
193
|
+
import time
|
|
194
|
+
|
|
195
|
+
import httpx
|
|
196
|
+
|
|
197
|
+
test_tokens = generate_test_tokens(token)
|
|
198
|
+
results: list[JWTCheckResult] = []
|
|
199
|
+
|
|
200
|
+
# baseline — original request
|
|
201
|
+
baseline_status = 0
|
|
202
|
+
async with httpx.AsyncClient(verify=False, timeout=timeout, http2=True) as client:
|
|
203
|
+
try:
|
|
204
|
+
url = f"{scheme}://{host}{path}" + (f"?{query}" if query else "")
|
|
205
|
+
req_headers = {k: ", ".join(v) for k, v in headers.items()}
|
|
206
|
+
resp = await client.request(method=method, url=url, headers=req_headers, content=body)
|
|
207
|
+
baseline_status = resp.status_code
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
for vector, description, modified_token in test_tokens:
|
|
212
|
+
mod_headers = dict(headers)
|
|
213
|
+
# Replace token in Authorization header
|
|
214
|
+
auth = ", ".join(headers.get("authorization", [""])).strip()
|
|
215
|
+
if auth.lower().startswith("bearer "):
|
|
216
|
+
mod_headers["authorization"] = [f"Bearer {modified_token}"]
|
|
217
|
+
else:
|
|
218
|
+
mod_headers["authorization"] = [f"Bearer {modified_token}"]
|
|
219
|
+
|
|
220
|
+
req_headers = {k: ", ".join(v) for k, v in mod_headers.items()}
|
|
221
|
+
url = f"{scheme}://{host}{path}" + (f"?{query}" if query else "")
|
|
222
|
+
|
|
223
|
+
start = time.monotonic()
|
|
224
|
+
status_code = 0
|
|
225
|
+
resp_body = b""
|
|
226
|
+
try:
|
|
227
|
+
async with httpx.AsyncClient(verify=False, timeout=timeout, http2=True) as client:
|
|
228
|
+
resp = await client.request(
|
|
229
|
+
method=method, url=url, headers=req_headers, content=body
|
|
230
|
+
)
|
|
231
|
+
status_code = resp.status_code
|
|
232
|
+
resp_body = resp.content
|
|
233
|
+
except Exception as e:
|
|
234
|
+
resp_body = str(e).encode()
|
|
235
|
+
|
|
236
|
+
dur = int((time.monotonic() - start) * 1000)
|
|
237
|
+
|
|
238
|
+
# Suspicious: server accepted modified token (2xx when baseline was also 2xx means no change;
|
|
239
|
+
# but if baseline was 401/403 and modified gets 2xx that's very suspicious)
|
|
240
|
+
suspicious = (status_code in range(200, 300) and baseline_status in (401, 403, 0)) or (
|
|
241
|
+
vector in ("none_alg", "alg_confusion_rs_hs", "empty_sig")
|
|
242
|
+
and status_code in range(200, 300)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
results.append(
|
|
246
|
+
JWTCheckResult(
|
|
247
|
+
vector=vector,
|
|
248
|
+
description=description,
|
|
249
|
+
modified_token=modified_token[:80] + "...",
|
|
250
|
+
status_code=status_code,
|
|
251
|
+
response_body=resp_body[:512],
|
|
252
|
+
duration_ms=dur,
|
|
253
|
+
suspicious=suspicious,
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
return results
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def extract_jwt_from_headers(headers: dict[str, list[str]]) -> str | None:
|
|
261
|
+
auth = headers.get("authorization", [""])
|
|
262
|
+
for val in auth:
|
|
263
|
+
m = re.match(r"Bearer\s+([A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]*)", val)
|
|
264
|
+
if m:
|
|
265
|
+
return m.group(1)
|
|
266
|
+
# Also check cookies
|
|
267
|
+
for val in headers.get("cookie", []):
|
|
268
|
+
m = re.search(r"([A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]*)", val)
|
|
269
|
+
if m:
|
|
270
|
+
token = m.group(1)
|
|
271
|
+
if _parse_jwt(token):
|
|
272
|
+
return token
|
|
273
|
+
return None
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pypproxy.store.models import Entry
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class PluginInfo:
|
|
16
|
+
name: str
|
|
17
|
+
version: str
|
|
18
|
+
description: str
|
|
19
|
+
path: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PluginBase:
|
|
23
|
+
"""Base class for paxy plugins. Override any hooks you need."""
|
|
24
|
+
|
|
25
|
+
name: str = "unnamed"
|
|
26
|
+
version: str = "0.1.0"
|
|
27
|
+
description: str = ""
|
|
28
|
+
|
|
29
|
+
def on_request(self, entry: Entry) -> Entry | None:
|
|
30
|
+
"""Called before a request is forwarded. Return modified entry or None to skip."""
|
|
31
|
+
return entry
|
|
32
|
+
|
|
33
|
+
def on_response(self, entry: Entry) -> Entry | None:
|
|
34
|
+
"""Called after a response is received. Return modified entry or None to skip."""
|
|
35
|
+
return entry
|
|
36
|
+
|
|
37
|
+
def on_entry_added(self, entry: Entry) -> None:
|
|
38
|
+
"""Called when an entry is added to the store."""
|
|
39
|
+
|
|
40
|
+
def ui_tab(self) -> dict | None:
|
|
41
|
+
"""Return {'title': str, 'build': callable(container)} to add a UI tab, or None."""
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PluginManager:
|
|
46
|
+
def __init__(self, plugin_dir: str | None = None) -> None:
|
|
47
|
+
self._plugins: list[PluginBase] = []
|
|
48
|
+
self._lock = threading.Lock()
|
|
49
|
+
self._plugin_dir = plugin_dir or str(Path.home() / ".paxy" / "plugins")
|
|
50
|
+
|
|
51
|
+
def load_directory(self) -> int:
|
|
52
|
+
"""Load all .py files from the plugin directory. Returns count loaded."""
|
|
53
|
+
d = Path(self._plugin_dir)
|
|
54
|
+
if not d.exists():
|
|
55
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
count = 0
|
|
59
|
+
for py_file in sorted(d.glob("*.py")):
|
|
60
|
+
if py_file.name.startswith("_"):
|
|
61
|
+
continue
|
|
62
|
+
try:
|
|
63
|
+
self.load_file(str(py_file))
|
|
64
|
+
count += 1
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.warning("Failed to load plugin %s: %s", py_file.name, e)
|
|
67
|
+
return count
|
|
68
|
+
|
|
69
|
+
def load_file(self, path: str) -> PluginBase:
|
|
70
|
+
spec = importlib.util.spec_from_file_location(f"paxy_plugin_{Path(path).stem}", path)
|
|
71
|
+
if spec is None or spec.loader is None:
|
|
72
|
+
raise ImportError(f"Cannot load: {path}")
|
|
73
|
+
mod = importlib.util.module_from_spec(spec)
|
|
74
|
+
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
|
75
|
+
|
|
76
|
+
# Find PluginBase subclass in module
|
|
77
|
+
plugin_cls = None
|
|
78
|
+
for attr in vars(mod).values():
|
|
79
|
+
if isinstance(attr, type) and issubclass(attr, PluginBase) and attr is not PluginBase:
|
|
80
|
+
plugin_cls = attr
|
|
81
|
+
break
|
|
82
|
+
|
|
83
|
+
if plugin_cls is None:
|
|
84
|
+
raise ImportError(f"No PluginBase subclass found in {path}")
|
|
85
|
+
|
|
86
|
+
instance = plugin_cls()
|
|
87
|
+
with self._lock:
|
|
88
|
+
self._plugins.append(instance)
|
|
89
|
+
logger.info("Loaded plugin: %s v%s from %s", instance.name, instance.version, path)
|
|
90
|
+
return instance
|
|
91
|
+
|
|
92
|
+
def unload(self, name: str) -> None:
|
|
93
|
+
with self._lock:
|
|
94
|
+
self._plugins = [p for p in self._plugins if p.name != name]
|
|
95
|
+
|
|
96
|
+
def list(self) -> list[PluginInfo]:
|
|
97
|
+
with self._lock:
|
|
98
|
+
return [
|
|
99
|
+
PluginInfo(
|
|
100
|
+
name=p.name,
|
|
101
|
+
version=p.version,
|
|
102
|
+
description=p.description,
|
|
103
|
+
path=getattr(p, "__module__", ""),
|
|
104
|
+
)
|
|
105
|
+
for p in self._plugins
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
def run_on_request(self, entry: Entry) -> Entry:
|
|
109
|
+
with self._lock:
|
|
110
|
+
plugins = list(self._plugins)
|
|
111
|
+
for p in plugins:
|
|
112
|
+
try:
|
|
113
|
+
result = p.on_request(entry)
|
|
114
|
+
if result is not None:
|
|
115
|
+
entry = result
|
|
116
|
+
except Exception as e:
|
|
117
|
+
logger.warning("Plugin %s on_request error: %s", p.name, e)
|
|
118
|
+
return entry
|
|
119
|
+
|
|
120
|
+
def run_on_response(self, entry: Entry) -> Entry:
|
|
121
|
+
with self._lock:
|
|
122
|
+
plugins = list(self._plugins)
|
|
123
|
+
for p in plugins:
|
|
124
|
+
try:
|
|
125
|
+
result = p.on_response(entry)
|
|
126
|
+
if result is not None:
|
|
127
|
+
entry = result
|
|
128
|
+
except Exception as e:
|
|
129
|
+
logger.warning("Plugin %s on_response error: %s", p.name, e)
|
|
130
|
+
return entry
|
|
131
|
+
|
|
132
|
+
def run_on_entry_added(self, entry: Entry) -> None:
|
|
133
|
+
with self._lock:
|
|
134
|
+
plugins = list(self._plugins)
|
|
135
|
+
for p in plugins:
|
|
136
|
+
try:
|
|
137
|
+
p.on_entry_added(entry)
|
|
138
|
+
except Exception as e:
|
|
139
|
+
logger.warning("Plugin %s on_entry_added error: %s", p.name, e)
|
|
140
|
+
|
|
141
|
+
def get_ui_tabs(self) -> list[dict]:
|
|
142
|
+
with self._lock:
|
|
143
|
+
plugins = list(self._plugins)
|
|
144
|
+
tabs = []
|
|
145
|
+
for p in plugins:
|
|
146
|
+
try:
|
|
147
|
+
tab = p.ui_tab()
|
|
148
|
+
if tab:
|
|
149
|
+
tabs.append(tab)
|
|
150
|
+
except Exception as e:
|
|
151
|
+
logger.warning("Plugin %s ui_tab error: %s", p.name, e)
|
|
152
|
+
return tabs
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class RandomnessResult:
|
|
11
|
+
test_name: str
|
|
12
|
+
passed: bool
|
|
13
|
+
score: float
|
|
14
|
+
detail: str
|
|
15
|
+
|
|
16
|
+
def to_dict(self) -> dict:
|
|
17
|
+
return {
|
|
18
|
+
"test_name": self.test_name,
|
|
19
|
+
"passed": self.passed,
|
|
20
|
+
"score": round(self.score, 4),
|
|
21
|
+
"detail": self.detail,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def analyse_token(token: str) -> list[RandomnessResult]:
|
|
26
|
+
"""Run statistical randomness tests on a token string."""
|
|
27
|
+
# Try to decode base64/base64url tokens
|
|
28
|
+
bits = _token_to_bits(token)
|
|
29
|
+
if not bits:
|
|
30
|
+
return [RandomnessResult("parse", False, 0.0, "Could not extract bits from token")]
|
|
31
|
+
|
|
32
|
+
results: list[RandomnessResult] = []
|
|
33
|
+
results.append(_frequency_test(bits))
|
|
34
|
+
results.append(_runs_test(bits))
|
|
35
|
+
results.append(_longest_run_test(bits))
|
|
36
|
+
results.append(_entropy_test(token))
|
|
37
|
+
results.append(_serial_test(bits))
|
|
38
|
+
return results
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _token_to_bits(token: str) -> list[int]:
|
|
42
|
+
# strip JWT signature part
|
|
43
|
+
if token.count(".") == 2:
|
|
44
|
+
token = token.split(".")[1] # use payload
|
|
45
|
+
|
|
46
|
+
# try base64url decode
|
|
47
|
+
for attempt in (token, token + "=" * (-len(token) % 4)):
|
|
48
|
+
try:
|
|
49
|
+
raw = base64.urlsafe_b64decode(attempt)
|
|
50
|
+
return [int(b) for byte in raw for b in format(byte, "08b")]
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
# treat as hex
|
|
55
|
+
try:
|
|
56
|
+
clean = re.sub(r"[^0-9a-fA-F]", "", token)
|
|
57
|
+
if len(clean) >= 16:
|
|
58
|
+
raw = bytes.fromhex(clean)
|
|
59
|
+
return [int(b) for byte in raw for b in format(byte, "08b")]
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
# use raw bytes
|
|
64
|
+
raw = token.encode()
|
|
65
|
+
return [int(b) for byte in raw for b in format(byte, "08b")]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _frequency_test(bits: list[int]) -> RandomnessResult:
|
|
69
|
+
"""NIST SP800-22 frequency (monobit) test."""
|
|
70
|
+
n = len(bits)
|
|
71
|
+
s = sum(1 if b else -1 for b in bits)
|
|
72
|
+
s_obs = abs(s) / math.sqrt(n)
|
|
73
|
+
p_value = math.erfc(s_obs / math.sqrt(2))
|
|
74
|
+
passed = p_value >= 0.01
|
|
75
|
+
ratio = sum(bits) / n
|
|
76
|
+
return RandomnessResult(
|
|
77
|
+
"Frequency (monobit)",
|
|
78
|
+
passed,
|
|
79
|
+
p_value,
|
|
80
|
+
f"Ones ratio={ratio:.3f} (ideal=0.5), p={p_value:.4f}",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _runs_test(bits: list[int]) -> RandomnessResult:
|
|
85
|
+
"""NIST runs test."""
|
|
86
|
+
n = len(bits)
|
|
87
|
+
ones = sum(bits)
|
|
88
|
+
pi = ones / n
|
|
89
|
+
|
|
90
|
+
if abs(pi - 0.5) >= 2 / math.sqrt(n):
|
|
91
|
+
return RandomnessResult(
|
|
92
|
+
"Runs",
|
|
93
|
+
False,
|
|
94
|
+
0.0,
|
|
95
|
+
f"Pre-condition failed: ones ratio={pi:.3f} too far from 0.5",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
v_obs = 1 + sum(1 for i in range(n - 1) if bits[i] != bits[i + 1])
|
|
99
|
+
num = abs(v_obs - 2 * n * pi * (1 - pi))
|
|
100
|
+
den = 2 * math.sqrt(2 * n) * pi * (1 - pi)
|
|
101
|
+
p_value = math.erfc(num / den)
|
|
102
|
+
passed = p_value >= 0.01
|
|
103
|
+
return RandomnessResult("Runs", passed, p_value, f"runs={v_obs}, p={p_value:.4f}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _longest_run_test(bits: list[int]) -> RandomnessResult:
|
|
107
|
+
"""Longest run of ones test (simplified)."""
|
|
108
|
+
longest = 0
|
|
109
|
+
current = 0
|
|
110
|
+
for b in bits:
|
|
111
|
+
if b == 1:
|
|
112
|
+
current += 1
|
|
113
|
+
longest = max(longest, current)
|
|
114
|
+
else:
|
|
115
|
+
current = 0
|
|
116
|
+
|
|
117
|
+
n = len(bits)
|
|
118
|
+
expected = math.log2(n) if n > 0 else 0
|
|
119
|
+
ratio = longest / expected if expected > 0 else float("inf")
|
|
120
|
+
passed = ratio < 3.0
|
|
121
|
+
return RandomnessResult(
|
|
122
|
+
"Longest run",
|
|
123
|
+
passed,
|
|
124
|
+
1.0 / ratio if ratio > 0 else 0.0,
|
|
125
|
+
f"longest_run={longest}, expected~{expected:.1f}, ratio={ratio:.2f}",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _entropy_test(token: str) -> RandomnessResult:
|
|
130
|
+
"""Shannon entropy of the token characters."""
|
|
131
|
+
from collections import Counter
|
|
132
|
+
|
|
133
|
+
counts = Counter(token)
|
|
134
|
+
n = len(token)
|
|
135
|
+
entropy = -sum((c / n) * math.log2(c / n) for c in counts.values() if c > 0)
|
|
136
|
+
max_entropy = math.log2(len(counts)) if len(counts) > 1 else 0
|
|
137
|
+
ratio = entropy / max_entropy if max_entropy > 0 else 0
|
|
138
|
+
passed = ratio >= 0.7
|
|
139
|
+
return RandomnessResult(
|
|
140
|
+
"Shannon entropy",
|
|
141
|
+
passed,
|
|
142
|
+
ratio,
|
|
143
|
+
f"entropy={entropy:.2f} bits, max={max_entropy:.2f}, ratio={ratio:.2f}",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _serial_test(bits: list[int]) -> RandomnessResult:
|
|
148
|
+
"""Serial (digram frequency) test."""
|
|
149
|
+
from collections import Counter
|
|
150
|
+
|
|
151
|
+
n = len(bits)
|
|
152
|
+
if n < 4:
|
|
153
|
+
return RandomnessResult("Serial", False, 0.0, "Too few bits")
|
|
154
|
+
|
|
155
|
+
digrams = Counter(zip(bits, bits[1:], strict=False))
|
|
156
|
+
expected = (n - 1) / 4
|
|
157
|
+
chi2 = sum((c - expected) ** 2 / expected for c in digrams.values())
|
|
158
|
+
# 3 degrees of freedom, rough threshold
|
|
159
|
+
passed = chi2 < 16.27 # chi2 p=0.001, df=3
|
|
160
|
+
return RandomnessResult(
|
|
161
|
+
"Serial (digram)",
|
|
162
|
+
passed,
|
|
163
|
+
1.0 / (1 + chi2 / 10),
|
|
164
|
+
f"chi2={chi2:.2f} (threshold<16.27)",
|
|
165
|
+
)
|
|
File without changes
|