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,308 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class HeaderCheckResult:
8
+ header: str
9
+ present: bool
10
+ value: str
11
+ passed: bool
12
+ severity: str # info, low, medium, high
13
+ detail: str
14
+
15
+ def to_dict(self) -> dict:
16
+ return {
17
+ "header": self.header,
18
+ "present": self.present,
19
+ "value": self.value,
20
+ "passed": self.passed,
21
+ "severity": self.severity,
22
+ "detail": self.detail,
23
+ }
24
+
25
+
26
+ def check_security_headers(resp_headers: dict[str, list[str]]) -> list[HeaderCheckResult]:
27
+ """Analyse response headers for security issues."""
28
+ flat = {k.lower(): ", ".join(v) for k, v in resp_headers.items()}
29
+ results: list[HeaderCheckResult] = []
30
+
31
+ results.extend(
32
+ [
33
+ _check_hsts(flat),
34
+ _check_csp(flat),
35
+ _check_x_frame(flat),
36
+ _check_x_content_type(flat),
37
+ _check_x_xss(flat),
38
+ _check_referrer_policy(flat),
39
+ _check_permissions_policy(flat),
40
+ _check_cors(flat),
41
+ _check_server(flat),
42
+ _check_x_powered_by(flat),
43
+ _check_cache_control(flat),
44
+ _check_set_cookie(flat),
45
+ ]
46
+ )
47
+
48
+ return results
49
+
50
+
51
+ def _check_hsts(h: dict) -> HeaderCheckResult:
52
+ key = "strict-transport-security"
53
+ val = h.get(key, "")
54
+ if not val:
55
+ return HeaderCheckResult(
56
+ key,
57
+ False,
58
+ "",
59
+ False,
60
+ "high",
61
+ "Missing HSTS header. Clients may connect over plain HTTP.",
62
+ )
63
+ max_age = 0
64
+ for part in val.split(";"):
65
+ part = part.strip().lower()
66
+ if part.startswith("max-age="):
67
+ import contextlib
68
+
69
+ with contextlib.suppress(ValueError):
70
+ max_age = int(part.split("=")[1])
71
+ passed = max_age >= 31536000
72
+ return HeaderCheckResult(
73
+ key,
74
+ True,
75
+ val,
76
+ passed,
77
+ "medium" if not passed else "info",
78
+ f"max-age={max_age} ({'≥1 year OK' if passed else '<1 year, consider increasing'})",
79
+ )
80
+
81
+
82
+ def _check_csp(h: dict) -> HeaderCheckResult:
83
+ key = "content-security-policy"
84
+ val = h.get(key, "")
85
+ if not val:
86
+ return HeaderCheckResult(
87
+ key, False, "", False, "high", "Missing CSP. XSS attacks are not mitigated."
88
+ )
89
+ issues = []
90
+ if "unsafe-inline" in val:
91
+ issues.append("'unsafe-inline' allows inline scripts")
92
+ if "unsafe-eval" in val:
93
+ issues.append("'unsafe-eval' allows eval()")
94
+ if "default-src *" in val or "script-src *" in val:
95
+ issues.append("Wildcard source too permissive")
96
+ passed = len(issues) == 0
97
+ detail = "OK" if passed else "; ".join(issues)
98
+ return HeaderCheckResult(
99
+ key, True, val[:80], passed, "medium" if not passed else "info", detail
100
+ )
101
+
102
+
103
+ def _check_x_frame(h: dict) -> HeaderCheckResult:
104
+ key = "x-frame-options"
105
+ val = h.get(key, "")
106
+ if not val:
107
+ # check CSP frame-ancestors instead
108
+ csp = h.get("content-security-policy", "")
109
+ if "frame-ancestors" in csp:
110
+ return HeaderCheckResult(
111
+ key,
112
+ False,
113
+ "",
114
+ True,
115
+ "info",
116
+ "Not present, but CSP frame-ancestors found (acceptable)",
117
+ )
118
+ return HeaderCheckResult(
119
+ key,
120
+ False,
121
+ "",
122
+ False,
123
+ "medium",
124
+ "Missing X-Frame-Options. Clickjacking may be possible.",
125
+ )
126
+ val_up = val.upper()
127
+ passed = "DENY" in val_up or "SAMEORIGIN" in val_up
128
+ return HeaderCheckResult(
129
+ key,
130
+ True,
131
+ val,
132
+ passed,
133
+ "info" if passed else "medium",
134
+ "OK" if passed else f"Unexpected value: {val}",
135
+ )
136
+
137
+
138
+ def _check_x_content_type(h: dict) -> HeaderCheckResult:
139
+ key = "x-content-type-options"
140
+ val = h.get(key, "")
141
+ passed = val.lower() == "nosniff"
142
+ if not val:
143
+ return HeaderCheckResult(
144
+ key, False, "", False, "low", "Missing. Browser may MIME-sniff responses."
145
+ )
146
+ return HeaderCheckResult(
147
+ key,
148
+ True,
149
+ val,
150
+ passed,
151
+ "info" if passed else "low",
152
+ "OK" if passed else f"Should be 'nosniff', got: {val}",
153
+ )
154
+
155
+
156
+ def _check_x_xss(h: dict) -> HeaderCheckResult:
157
+ key = "x-xss-protection"
158
+ val = h.get(key, "")
159
+ if not val:
160
+ return HeaderCheckResult(
161
+ key,
162
+ False,
163
+ "",
164
+ True,
165
+ "info",
166
+ "Not present (modern browsers ignore this; rely on CSP instead)",
167
+ )
168
+ passed = "1; mode=block" in val or val == "0"
169
+ return HeaderCheckResult(
170
+ key, True, val, passed, "info", "OK" if passed else f"Unusual value: {val}"
171
+ )
172
+
173
+
174
+ def _check_referrer_policy(h: dict) -> HeaderCheckResult:
175
+ key = "referrer-policy"
176
+ val = h.get(key, "")
177
+ safe = {
178
+ "no-referrer",
179
+ "no-referrer-when-downgrade",
180
+ "strict-origin",
181
+ "strict-origin-when-cross-origin",
182
+ }
183
+ passed = any(s in val.lower() for s in safe)
184
+ if not val:
185
+ return HeaderCheckResult(
186
+ key, False, "", False, "low", "Missing. Full URL may be leaked in Referer header."
187
+ )
188
+ return HeaderCheckResult(
189
+ key,
190
+ True,
191
+ val,
192
+ passed,
193
+ "info" if passed else "low",
194
+ "OK" if passed else f"Consider stricter policy, got: {val}",
195
+ )
196
+
197
+
198
+ def _check_permissions_policy(h: dict) -> HeaderCheckResult:
199
+ key = "permissions-policy"
200
+ val = h.get(key, h.get("feature-policy", ""))
201
+ present = bool(val)
202
+ return HeaderCheckResult(
203
+ key,
204
+ present,
205
+ val[:80] if val else "",
206
+ present,
207
+ "info" if present else "low",
208
+ "OK" if present else "Consider adding Permissions-Policy to restrict browser features",
209
+ )
210
+
211
+
212
+ def _check_cors(h: dict) -> HeaderCheckResult:
213
+ key = "access-control-allow-origin"
214
+ val = h.get(key, "")
215
+ if not val:
216
+ return HeaderCheckResult(
217
+ key, False, "", True, "info", "Not present (CORS not enabled for this endpoint)"
218
+ )
219
+ passed = val != "*"
220
+ return HeaderCheckResult(
221
+ key,
222
+ True,
223
+ val,
224
+ passed,
225
+ "high" if not passed else "info",
226
+ "Wildcard ACAO allows cross-origin access from any domain" if not passed else "OK",
227
+ )
228
+
229
+
230
+ def _check_server(h: dict) -> HeaderCheckResult:
231
+ key = "server"
232
+ val = h.get(key, "")
233
+ if not val:
234
+ return HeaderCheckResult(key, False, "", True, "info", "Server header not exposed (good)")
235
+ # Check for version disclosure
236
+ import re
237
+
238
+ has_version = bool(re.search(r"[0-9]+\.[0-9]+", val))
239
+ passed = not has_version
240
+ return HeaderCheckResult(
241
+ key,
242
+ True,
243
+ val,
244
+ passed,
245
+ "low" if not passed else "info",
246
+ "Version number disclosed in Server header" if not passed else "No version disclosed",
247
+ )
248
+
249
+
250
+ def _check_x_powered_by(h: dict) -> HeaderCheckResult:
251
+ key = "x-powered-by"
252
+ val = h.get(key, "")
253
+ passed = not val
254
+ return HeaderCheckResult(
255
+ key,
256
+ bool(val),
257
+ val,
258
+ passed,
259
+ "low" if not passed else "info",
260
+ "Technology stack disclosed" if not passed else "Not present (good)",
261
+ )
262
+
263
+
264
+ def _check_cache_control(h: dict) -> HeaderCheckResult:
265
+ key = "cache-control"
266
+ val = h.get(key, "")
267
+ if not val:
268
+ return HeaderCheckResult(
269
+ key,
270
+ False,
271
+ "",
272
+ False,
273
+ "low",
274
+ "Missing Cache-Control. Sensitive responses may be cached.",
275
+ )
276
+ sensitive_ok = "no-store" in val or "private" in val
277
+ return HeaderCheckResult(
278
+ key,
279
+ True,
280
+ val,
281
+ True,
282
+ "info",
283
+ f"{'Contains no-store/private' if sensitive_ok else 'Consider no-store for sensitive endpoints'}",
284
+ )
285
+
286
+
287
+ def _check_set_cookie(h: dict) -> HeaderCheckResult:
288
+ key = "set-cookie"
289
+ val = h.get(key, "")
290
+ if not val:
291
+ return HeaderCheckResult(key, False, "", True, "info", "No Set-Cookie header")
292
+ issues = []
293
+ val_lower = val.lower()
294
+ if "secure" not in val_lower:
295
+ issues.append("Missing 'Secure' flag (cookie sent over HTTP)")
296
+ if "httponly" not in val_lower:
297
+ issues.append("Missing 'HttpOnly' flag (accessible via JavaScript)")
298
+ if "samesite" not in val_lower:
299
+ issues.append("Missing 'SameSite' attribute (CSRF risk)")
300
+ passed = len(issues) == 0
301
+ return HeaderCheckResult(
302
+ key,
303
+ True,
304
+ val[:80],
305
+ passed,
306
+ "medium" if not passed else "info",
307
+ "; ".join(issues) if issues else "OK",
308
+ )
@@ -0,0 +1,193 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+ from urllib.parse import parse_qs, urlencode
9
+
10
+ import httpx
11
+
12
+ from pypproxy.store.models import Entry
13
+
14
+
15
+ @dataclass
16
+ class OverflowPayload:
17
+ label: str
18
+ description: str
19
+ value: Any
20
+
21
+
22
+ @dataclass
23
+ class OverflowResult:
24
+ param: str
25
+ payload: OverflowPayload
26
+ status_code: int = 0
27
+ response_body: bytes = b""
28
+ duration_ms: int = 0
29
+ error: str = ""
30
+ suspicious: bool = False
31
+
32
+ def to_dict(self) -> dict:
33
+ import base64
34
+
35
+ return {
36
+ "param": self.param,
37
+ "label": self.payload.label,
38
+ "description": self.payload.description,
39
+ "value": str(self.payload.value),
40
+ "status_code": self.status_code,
41
+ "response_body": base64.b64encode(self.response_body).decode()
42
+ if self.response_body
43
+ else "",
44
+ "duration_ms": self.duration_ms,
45
+ "error": self.error,
46
+ "suspicious": self.suspicious,
47
+ }
48
+
49
+
50
+ _INT_PAYLOADS = [
51
+ OverflowPayload("plus_one", "+1 from original", None), # filled dynamically
52
+ OverflowPayload("minus_one", "-1 from original", None),
53
+ OverflowPayload("zero", "Zero value", 0),
54
+ OverflowPayload("negative", "Large negative", -2147483648),
55
+ OverflowPayload("max_int32", "Max int32", 2147483647),
56
+ OverflowPayload("max_int32_plus1", "Max int32 + 1 (overflow)", 2147483648),
57
+ OverflowPayload("max_int64", "Max int64", 9223372036854775807),
58
+ OverflowPayload("max_uint32", "Max uint32", 4294967295),
59
+ OverflowPayload("long_num", "Very long number", 99999999999999999999),
60
+ OverflowPayload("float", "Float (0.1)", 0.1),
61
+ OverflowPayload("neg_float", "Negative float", -0.1),
62
+ OverflowPayload("sci_notation", "Scientific notation", "1e308"),
63
+ OverflowPayload("nan", "NaN string", "NaN"),
64
+ OverflowPayload("inf", "Infinity string", "Infinity"),
65
+ ]
66
+
67
+
68
+ def _extract_int_params(entry: Entry) -> dict[str, int]:
69
+ """Find integer-valued parameters in query string and JSON body."""
70
+ params: dict[str, int] = {}
71
+
72
+ # query string
73
+ if entry.query:
74
+ import contextlib
75
+
76
+ for k, vs in parse_qs(entry.query).items():
77
+ for v in vs:
78
+ with contextlib.suppress(ValueError):
79
+ params[f"query:{k}"] = int(v)
80
+
81
+ # JSON body
82
+ if entry.req_body:
83
+ try:
84
+ data = json.loads(entry.req_body.decode("utf-8", errors="replace"))
85
+ _collect_ints(data, "", params)
86
+ except Exception:
87
+ pass
88
+
89
+ return params
90
+
91
+
92
+ def _collect_ints(obj: Any, prefix: str, out: dict[str, int]) -> None:
93
+ if isinstance(obj, dict):
94
+ for k, v in obj.items():
95
+ _collect_ints(v, f"body:{prefix}{k}", out)
96
+ elif isinstance(obj, list):
97
+ for i, v in enumerate(obj):
98
+ _collect_ints(v, f"{prefix}[{i}].", out)
99
+ elif isinstance(obj, int) and not isinstance(obj, bool):
100
+ out[prefix.rstrip(".")] = obj
101
+
102
+
103
+ def _apply_to_query(query: str, param_key: str, new_value: Any) -> str:
104
+ key = param_key.removeprefix("query:")
105
+ qs = parse_qs(query)
106
+ qs[key] = [str(new_value)]
107
+ return urlencode(qs, doseq=True)
108
+
109
+
110
+ def _apply_to_body(body: bytes, param_key: str, new_value: Any) -> bytes:
111
+ key_path = param_key.removeprefix("body:")
112
+ try:
113
+ data = json.loads(body.decode("utf-8", errors="replace"))
114
+ _set_nested(data, key_path, new_value)
115
+ return json.dumps(data).encode()
116
+ except Exception:
117
+ return body
118
+
119
+
120
+ def _set_nested(obj: Any, key_path: str, value: Any) -> None:
121
+ parts = re.split(r"[.\[\]]", key_path)
122
+ parts = [p for p in parts if p]
123
+ for part in parts[:-1]:
124
+ if isinstance(obj, dict):
125
+ obj = obj.get(part, {})
126
+ elif isinstance(obj, list):
127
+ obj = obj[int(part)]
128
+ last = parts[-1]
129
+ if isinstance(obj, dict):
130
+ obj[last] = value
131
+
132
+
133
+ async def run_checks(entry: Entry, timeout: int = 30) -> list[OverflowResult]:
134
+ int_params = _extract_int_params(entry)
135
+ if not int_params:
136
+ return []
137
+
138
+ results: list[OverflowResult] = []
139
+ url_base = f"{entry.scheme}://{entry.host}{entry.path}"
140
+ req_headers = {k: ", ".join(v) for k, v in entry.req_headers.items()}
141
+
142
+ for param_key, original_value in int_params.items():
143
+ payloads = []
144
+ for p in _INT_PAYLOADS:
145
+ pl = OverflowPayload(p.label, p.description, p.value)
146
+ if p.label == "plus_one":
147
+ pl.value = original_value + 1
148
+ elif p.label == "minus_one":
149
+ pl.value = original_value - 1
150
+ payloads.append(pl)
151
+
152
+ for payload in payloads:
153
+ if param_key.startswith("query:"):
154
+ new_query = _apply_to_query(entry.query, param_key, payload.value)
155
+ url = url_base + ("?" + new_query if new_query else "")
156
+ req_body = entry.req_body
157
+ else:
158
+ url = url_base + ("?" + entry.query if entry.query else "")
159
+ req_body = _apply_to_body(entry.req_body, param_key, payload.value)
160
+
161
+ start = time.monotonic()
162
+ try:
163
+ async with httpx.AsyncClient(verify=False, timeout=timeout, http2=True) as client:
164
+ resp = await client.request(
165
+ method=entry.method,
166
+ url=url,
167
+ headers=req_headers,
168
+ content=req_body,
169
+ )
170
+ status_code = resp.status_code
171
+ resp_body = resp.content[:512]
172
+ error = ""
173
+ suspicious = status_code in (500, 502, 503)
174
+ except Exception as e:
175
+ status_code = 0
176
+ resp_body = b""
177
+ error = str(e)
178
+ suspicious = False
179
+
180
+ dur = int((time.monotonic() - start) * 1000)
181
+ results.append(
182
+ OverflowResult(
183
+ param=param_key,
184
+ payload=payload,
185
+ status_code=status_code,
186
+ response_body=resp_body,
187
+ duration_ms=dur,
188
+ error=error,
189
+ suspicious=suspicious,
190
+ )
191
+ )
192
+
193
+ return results