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
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
|
pypproxy/scan/scanner.py
ADDED
|
@@ -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
|