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
pypproxy/rule/rule.py ADDED
@@ -0,0 +1,198 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from enum import StrEnum
6
+
7
+
8
+ class Action(StrEnum):
9
+ PASSTHROUGH = "passthrough"
10
+ MODIFY = "modify"
11
+ BLOCK = "block"
12
+ REDIRECT = "redirect"
13
+
14
+
15
+ class MatchField(StrEnum):
16
+ HOST = "host"
17
+ PATH = "path"
18
+ METHOD = "method"
19
+ HEADER = "header"
20
+ BODY = "body"
21
+
22
+
23
+ @dataclass
24
+ class Condition:
25
+ field: MatchField
26
+ op: str # equals, contains, prefix, regex
27
+ value: str
28
+ negate: bool = False
29
+ _compiled: re.Pattern | None = field(default=None, init=False, repr=False)
30
+
31
+ def matches(self, ctx: MatchContext) -> bool:
32
+ val = self._extract(ctx)
33
+ result = self._apply_op(val)
34
+ return not result if self.negate else result
35
+
36
+ def _extract(self, ctx: MatchContext) -> str:
37
+ if self.field == MatchField.HOST:
38
+ return ctx.host
39
+ if self.field == MatchField.PATH:
40
+ return ctx.path
41
+ if self.field == MatchField.METHOD:
42
+ return ctx.method
43
+ if self.field == MatchField.BODY:
44
+ return ctx.body.decode(errors="replace")
45
+ if self.field == MatchField.HEADER:
46
+ name = (
47
+ self.value.split(":")[0].strip().lower()
48
+ if ":" in self.value
49
+ else self.value.lower()
50
+ )
51
+ for k, vs in ctx.headers.items():
52
+ if k.lower() == name:
53
+ return ", ".join(vs)
54
+ return ""
55
+
56
+ def _apply_op(self, val: str) -> bool:
57
+ if self.op == "equals":
58
+ return val == self.value
59
+ if self.op == "contains":
60
+ return self.value in val
61
+ if self.op == "prefix":
62
+ return val.startswith(self.value)
63
+ if self.op == "regex":
64
+ if self._compiled is None:
65
+ self._compiled = re.compile(self.value)
66
+ return bool(self._compiled.search(val))
67
+ return self.value in val
68
+
69
+
70
+ @dataclass
71
+ class Modification:
72
+ target: str # req_header, resp_header, req_body, resp_body
73
+ operation: str # set, delete, append, replace, find_replace
74
+ key: str = ""
75
+ value: str = ""
76
+ find: str = ""
77
+ replace: str = ""
78
+
79
+
80
+ @dataclass
81
+ class Rule:
82
+ id: int = 0
83
+ name: str = ""
84
+ enabled: bool = True
85
+ priority: int = 0
86
+ conditions: list[Condition] = field(default_factory=list)
87
+ action: Action = Action.PASSTHROUGH
88
+ modifications: list[Modification] = field(default_factory=list)
89
+ redirect_url: str = ""
90
+
91
+ def matches(self, ctx: MatchContext) -> bool:
92
+ return all(c.matches(ctx) for c in self.conditions)
93
+
94
+ def to_dict(self) -> dict:
95
+ return {
96
+ "id": self.id,
97
+ "name": self.name,
98
+ "enabled": self.enabled,
99
+ "priority": self.priority,
100
+ "conditions": [
101
+ {
102
+ "field": c.field.value,
103
+ "op": c.op,
104
+ "value": c.value,
105
+ "negate": c.negate,
106
+ }
107
+ for c in self.conditions
108
+ ],
109
+ "action": self.action.value,
110
+ "modifications": [
111
+ {
112
+ "target": m.target,
113
+ "operation": m.operation,
114
+ "key": m.key,
115
+ "value": m.value,
116
+ "find": m.find,
117
+ "replace": m.replace,
118
+ }
119
+ for m in self.modifications
120
+ ],
121
+ "redirect_url": self.redirect_url,
122
+ }
123
+
124
+ @classmethod
125
+ def from_dict(cls, data: dict) -> Rule:
126
+ rule = cls(
127
+ id=data.get("id", 0),
128
+ name=data.get("name", ""),
129
+ enabled=data.get("enabled", True),
130
+ priority=data.get("priority", 0),
131
+ action=Action(data.get("action", "passthrough")),
132
+ redirect_url=data.get("redirect_url", ""),
133
+ )
134
+ for c in data.get("conditions", []):
135
+ rule.conditions.append(
136
+ Condition(
137
+ field=MatchField(c["field"]),
138
+ op=c.get("op", "contains"),
139
+ value=c.get("value", ""),
140
+ negate=c.get("negate", False),
141
+ )
142
+ )
143
+ for m in data.get("modifications", []):
144
+ rule.modifications.append(
145
+ Modification(
146
+ target=m["target"],
147
+ operation=m.get("operation", "set"),
148
+ key=m.get("key", ""),
149
+ value=m.get("value", ""),
150
+ find=m.get("find", ""),
151
+ replace=m.get("replace", ""),
152
+ )
153
+ )
154
+ return rule
155
+
156
+
157
+ @dataclass
158
+ class MatchContext:
159
+ method: str
160
+ host: str
161
+ path: str
162
+ headers: dict[str, list[str]]
163
+ body: bytes
164
+
165
+
166
+ class RuleManager:
167
+ def __init__(self) -> None:
168
+ self._rules: list[Rule] = []
169
+ self._counter = 0
170
+
171
+ def add(self, rule: Rule) -> Rule:
172
+ self._counter += 1
173
+ rule.id = self._counter
174
+ self._rules.append(rule)
175
+ self._sort()
176
+ return rule
177
+
178
+ def update(self, rule: Rule) -> None:
179
+ for i, r in enumerate(self._rules):
180
+ if r.id == rule.id:
181
+ self._rules[i] = rule
182
+ self._sort()
183
+ return
184
+
185
+ def delete(self, rule_id: int) -> None:
186
+ self._rules = [r for r in self._rules if r.id != rule_id]
187
+
188
+ def list(self) -> list[Rule]:
189
+ return list(self._rules)
190
+
191
+ def match(self, ctx: MatchContext) -> Rule | None:
192
+ for rule in self._rules:
193
+ if rule.enabled and rule.matches(ctx):
194
+ return rule
195
+ return None
196
+
197
+ def _sort(self) -> None:
198
+ self._rules.sort(key=lambda r: r.priority, reverse=True)
File without changes
@@ -0,0 +1,296 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import re
6
+ import time
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+ from urllib.parse import parse_qs, urlencode
10
+
11
+ import httpx
12
+
13
+ from pypproxy.store.models import Entry
14
+
15
+ # ---- Payload lists ----
16
+
17
+ XSS_PAYLOADS = [
18
+ "<script>alert(1)</script>",
19
+ '"><script>alert(1)</script>',
20
+ "';alert(1)//",
21
+ "<img src=x onerror=alert(1)>",
22
+ "<svg onload=alert(1)>",
23
+ "javascript:alert(1)",
24
+ '"><img src=x onerror=alert`1`>',
25
+ "${alert(1)}",
26
+ "{{7*7}}", # SSTI probe
27
+ "#{7*7}",
28
+ ]
29
+
30
+ SQLI_PAYLOADS = [
31
+ "'",
32
+ "''",
33
+ "' OR '1'='1",
34
+ "' OR '1'='1'--",
35
+ "1' OR '1'='1",
36
+ "1 OR 1=1",
37
+ "1; DROP TABLE users--",
38
+ "' UNION SELECT NULL--",
39
+ "' AND 1=2 UNION SELECT NULL,NULL--",
40
+ "admin'--",
41
+ "' OR 1=1#",
42
+ "1' AND SLEEP(2)--", # time-based blind
43
+ ]
44
+
45
+ CMDI_PAYLOADS = [
46
+ "; ls",
47
+ "| ls",
48
+ "&& ls",
49
+ "|| ls",
50
+ "; cat /etc/passwd",
51
+ "$(id)",
52
+ "`id`",
53
+ "; ping -c 1 127.0.0.1",
54
+ "| whoami",
55
+ ]
56
+
57
+ SSTI_PAYLOADS = [
58
+ "{{7*7}}",
59
+ "${7*7}",
60
+ "#{7*7}",
61
+ "<%= 7*7 %>",
62
+ "{{config}}",
63
+ "{{''.__class__.__mro__}}",
64
+ ]
65
+
66
+ PATH_TRAVERSAL_PAYLOADS = [
67
+ "../../etc/passwd",
68
+ "../../../etc/passwd",
69
+ "..\\..\\windows\\system32\\drivers\\etc\\hosts",
70
+ "....//....//etc/passwd",
71
+ "%2e%2e%2f%2e%2e%2fetc%2fpasswd",
72
+ ]
73
+
74
+ ALL_PAYLOADS: dict[str, list[str]] = {
75
+ "xss": XSS_PAYLOADS,
76
+ "sqli": SQLI_PAYLOADS,
77
+ "cmdi": CMDI_PAYLOADS,
78
+ "ssti": SSTI_PAYLOADS,
79
+ "path_traversal": PATH_TRAVERSAL_PAYLOADS,
80
+ }
81
+
82
+
83
+ @dataclass
84
+ class ScanResult:
85
+ param: str
86
+ category: str
87
+ payload: str
88
+ status_code: int = 0
89
+ response_body: bytes = b""
90
+ duration_ms: int = 0
91
+ error: str = ""
92
+ suspicious: bool = False
93
+ reason: str = ""
94
+
95
+ def to_dict(self) -> dict:
96
+ import base64
97
+
98
+ return {
99
+ "param": self.param,
100
+ "category": self.category,
101
+ "payload": self.payload,
102
+ "status_code": self.status_code,
103
+ "response_body": base64.b64encode(self.response_body).decode()
104
+ if self.response_body
105
+ else "",
106
+ "duration_ms": self.duration_ms,
107
+ "error": self.error,
108
+ "suspicious": self.suspicious,
109
+ "reason": self.reason,
110
+ }
111
+
112
+
113
+ def _extract_params(entry: Entry) -> dict[str, str]:
114
+ """Extract injectable parameters from query string and JSON body."""
115
+ params: dict[str, str] = {}
116
+
117
+ if entry.query:
118
+ for k, vs in parse_qs(entry.query).items():
119
+ params[f"query:{k}"] = vs[0] if vs else ""
120
+
121
+ if entry.req_body:
122
+ try:
123
+ data = json.loads(entry.req_body.decode("utf-8", errors="replace"))
124
+ _collect_strings(data, "", params)
125
+ except Exception:
126
+ pass
127
+
128
+ return params
129
+
130
+
131
+ def _collect_strings(obj: Any, prefix: str, out: dict[str, str]) -> None:
132
+ if isinstance(obj, dict):
133
+ for k, v in obj.items():
134
+ _collect_strings(v, f"body:{prefix}{k}", out)
135
+ elif isinstance(obj, list):
136
+ for i, v in enumerate(obj):
137
+ _collect_strings(v, f"{prefix}[{i}].", out)
138
+ elif isinstance(obj, str):
139
+ out[prefix.rstrip(".")] = obj
140
+
141
+
142
+ def _apply_payload(entry: Entry, param_key: str, payload: str) -> tuple[str, bytes]:
143
+ """Apply payload to the appropriate parameter. Returns (url, body)."""
144
+ url = f"{entry.scheme}://{entry.host}{entry.path}"
145
+ body = entry.req_body
146
+
147
+ if param_key.startswith("query:"):
148
+ key = param_key.removeprefix("query:")
149
+ qs = parse_qs(entry.query)
150
+ qs[key] = [payload]
151
+ url += "?" + urlencode(qs, doseq=True)
152
+ elif param_key.startswith("body:"):
153
+ key_path = param_key.removeprefix("body:")
154
+ try:
155
+ data = json.loads(body.decode("utf-8", errors="replace"))
156
+ _set_nested(data, key_path, payload)
157
+ body = json.dumps(data).encode()
158
+ except Exception:
159
+ pass
160
+ if entry.query:
161
+ url += "?" + entry.query
162
+ else:
163
+ if entry.query:
164
+ url += "?" + entry.query
165
+
166
+ return url, body
167
+
168
+
169
+ def _set_nested(obj: Any, key_path: str, value: Any) -> None:
170
+ parts = re.split(r"[.\[\]]", key_path)
171
+ parts = [p for p in parts if p]
172
+ for part in parts[:-1]:
173
+ if isinstance(obj, dict):
174
+ obj = obj.get(part, {})
175
+ elif isinstance(obj, list):
176
+ try:
177
+ obj = obj[int(part)]
178
+ except (ValueError, IndexError):
179
+ return
180
+ if parts:
181
+ last = parts[-1]
182
+ if isinstance(obj, dict):
183
+ obj[last] = value
184
+
185
+
186
+ def _is_suspicious(resp_body: bytes, payload: str, category: str, status: int) -> tuple[bool, str]:
187
+ """Heuristic check for vulnerability indicators."""
188
+ text = resp_body.decode("utf-8", errors="replace").lower()
189
+
190
+ if category == "xss" and payload.lower() in text:
191
+ return True, "Payload reflected in response"
192
+
193
+ if category == "sqli":
194
+ sql_errors = [
195
+ "sql syntax",
196
+ "mysql",
197
+ "sqlite",
198
+ "ora-",
199
+ "postgresql",
200
+ "odbc",
201
+ "jdbc",
202
+ "syntax error",
203
+ "unclosed quotation",
204
+ "you have an error in your sql",
205
+ ]
206
+ for err in sql_errors:
207
+ if err in text:
208
+ return True, f"SQL error signature: '{err}'"
209
+ if payload == "1' AND SLEEP(2)--":
210
+ return False, "" # time-based handled separately
211
+
212
+ if category == "cmdi":
213
+ cmdi_indicators = ["root:", "uid=", "bin/", "directory of", "volume serial"]
214
+ for ind in cmdi_indicators:
215
+ if ind in text:
216
+ return True, f"Command output indicator: '{ind}'"
217
+
218
+ if category == "ssti":
219
+ if "49" in text and "{{7*7}}" in payload:
220
+ return True, "SSTI: 7*7=49 reflected"
221
+ if "49" in text and "${7*7}" in payload:
222
+ return True, "SSTI: 7*7=49 reflected"
223
+
224
+ if category == "path_traversal":
225
+ pt_indicators = ["root:x:", "[boot loader]", "for 1 file"]
226
+ for ind in pt_indicators:
227
+ if ind in text:
228
+ return True, f"File content indicator: '{ind}'"
229
+
230
+ if status == 500:
231
+ return True, "Server error (500) — possible injection point"
232
+
233
+ return False, ""
234
+
235
+
236
+ async def run_scan(
237
+ entry: Entry,
238
+ categories: list[str] | None = None,
239
+ concurrency: int = 5,
240
+ timeout: int = 15,
241
+ ) -> list[ScanResult]:
242
+ """Run active scan on the entry. Returns list of findings."""
243
+ if categories is None:
244
+ categories = list(ALL_PAYLOADS.keys())
245
+
246
+ params = _extract_params(entry)
247
+ if not params:
248
+ return []
249
+
250
+ sem = asyncio.Semaphore(concurrency)
251
+ req_headers = {k: ", ".join(v) for k, v in entry.req_headers.items()}
252
+ tasks = []
253
+
254
+ for param_key in params:
255
+ for cat in categories:
256
+ if cat not in ALL_PAYLOADS:
257
+ continue
258
+ for payload in ALL_PAYLOADS[cat]:
259
+ tasks.append((param_key, cat, payload))
260
+
261
+ async def _test(param_key: str, cat: str, payload: str) -> ScanResult:
262
+ async with sem:
263
+ url, body = _apply_payload(entry, param_key, payload)
264
+ start = time.monotonic()
265
+ try:
266
+ async with httpx.AsyncClient(verify=False, timeout=timeout, http2=True) as client:
267
+ resp = await client.request(
268
+ method=entry.method,
269
+ url=url,
270
+ headers=req_headers,
271
+ content=body,
272
+ )
273
+ status = resp.status_code
274
+ resp_body = resp.content[:1024]
275
+ error = ""
276
+ except Exception as e:
277
+ status = 0
278
+ resp_body = b""
279
+ error = str(e)
280
+
281
+ dur = int((time.monotonic() - start) * 1000)
282
+ suspicious, reason = _is_suspicious(resp_body, payload, cat, status)
283
+ return ScanResult(
284
+ param=param_key,
285
+ category=cat,
286
+ payload=payload,
287
+ status_code=status,
288
+ response_body=resp_body,
289
+ duration_ms=dur,
290
+ error=error,
291
+ suspicious=suspicious,
292
+ reason=reason,
293
+ )
294
+
295
+ gathered = await asyncio.gather(*[_test(p, c, pl) for p, c, pl in tasks])
296
+ return list(gathered)
File without changes
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ from pathlib import Path
5
+
6
+
7
+ class ScriptEngine:
8
+ """
9
+ Loads a Python script and calls on_request / on_response hooks if defined.
10
+ The script runs in its own module namespace.
11
+ """
12
+
13
+ def __init__(self) -> None:
14
+ self._module = None
15
+
16
+ def load_file(self, path: str) -> None:
17
+ p = Path(path).resolve()
18
+ spec = importlib.util.spec_from_file_location("paxy_script", p)
19
+ if spec is None or spec.loader is None:
20
+ raise ImportError(f"Cannot load script: {path}")
21
+ mod = importlib.util.module_from_spec(spec)
22
+ spec.loader.exec_module(mod) # type: ignore[union-attr]
23
+ self._module = mod
24
+
25
+ def on_request(self, method: str, host: str, path: str, body: bytes) -> bytes:
26
+ if self._module is None:
27
+ return body
28
+ fn = getattr(self._module, "on_request", None)
29
+ if fn is None:
30
+ return body
31
+ result = fn(method, host, path, body)
32
+ if isinstance(result, bytes | bytearray):
33
+ return bytes(result)
34
+ if isinstance(result, str):
35
+ return result.encode()
36
+ return body
37
+
38
+ def on_response(self, status: int, body: bytes) -> bytes:
39
+ if self._module is None:
40
+ return body
41
+ fn = getattr(self._module, "on_response", None)
42
+ if fn is None:
43
+ return body
44
+ result = fn(status, body)
45
+ if isinstance(result, bytes | bytearray):
46
+ return bytes(result)
47
+ if isinstance(result, str):
48
+ return result.encode()
49
+ return body
File without changes