stryx-cli 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.
- stryx/__init__.py +35 -0
- stryx/ai/__init__.py +5 -0
- stryx/ai/attack_planner.py +199 -0
- stryx/ai/payload_generator.py +212 -0
- stryx/ai/prompts.py +85 -0
- stryx/ai/providers.py +249 -0
- stryx/attacks/__init__.py +6 -0
- stryx/attacks/attack_chain.py +111 -0
- stryx/attacks/replay.py +186 -0
- stryx/auth/__init__.py +1 -0
- stryx/auth/session_manager.py +335 -0
- stryx/cli.py +675 -0
- stryx/comparison/__init__.py +1 -0
- stryx/comparison/differ.py +255 -0
- stryx/config/__init__.py +6 -0
- stryx/config/default_config.yaml +12 -0
- stryx/config/loader.py +111 -0
- stryx/config/schema.py +80 -0
- stryx/crawler/__init__.py +5 -0
- stryx/crawler/discovery.py +178 -0
- stryx/crawler/graphql_discovery.py +86 -0
- stryx/crawler/html_crawler.py +294 -0
- stryx/crawler/js_endpoints.py +165 -0
- stryx/crawler/openapi.py +76 -0
- stryx/crawler/sitemap.py +94 -0
- stryx/orchestrator.py +347 -0
- stryx/payloads/cmdi.txt +31 -0
- stryx/payloads/header_injection.txt +15 -0
- stryx/payloads/ldap.txt +19 -0
- stryx/payloads/nosqli.txt +21 -0
- stryx/payloads/open_redirect.txt +23 -0
- stryx/payloads/path_traversal.txt +26 -0
- stryx/payloads/sqli.txt +31 -0
- stryx/payloads/ssrf.txt +30 -0
- stryx/payloads/ssti.txt +21 -0
- stryx/payloads/xss.txt +48 -0
- stryx/payloads/xxe.txt +35 -0
- stryx/plugins/__init__.py +5 -0
- stryx/plugins/base.py +85 -0
- stryx/policy/__init__.py +1 -0
- stryx/policy/engine.py +201 -0
- stryx/py.typed +0 -0
- stryx/reports/__init__.py +5 -0
- stryx/reports/generator.py +122 -0
- stryx/reports/json_report.py +72 -0
- stryx/reports/markdown_report.py +101 -0
- stryx/reports/sarif_report.py +200 -0
- stryx/reports/templates/report.html.j2 +163 -0
- stryx/reports/terminal_report.py +138 -0
- stryx/scanners/__init__.py +17 -0
- stryx/scanners/auth.py +444 -0
- stryx/scanners/authorization.py +220 -0
- stryx/scanners/blind.py +328 -0
- stryx/scanners/cloud_ssrf.py +208 -0
- stryx/scanners/cors.py +158 -0
- stryx/scanners/dependencies.py +296 -0
- stryx/scanners/disclosure.py +339 -0
- stryx/scanners/fuzz.py +391 -0
- stryx/scanners/graphql.py +309 -0
- stryx/scanners/injection.py +511 -0
- stryx/scanners/race.py +257 -0
- stryx/signatures/framework_fingerprints.yaml +122 -0
- stryx/signatures/known_vulns.yaml +210 -0
- stryx/utils/__init__.py +7 -0
- stryx/utils/evidence.py +109 -0
- stryx/utils/http_client.py +159 -0
- stryx/utils/logging.py +51 -0
- stryx/utils/rate_limiter.py +37 -0
- stryx_cli-0.1.0.dist-info/METADATA +236 -0
- stryx_cli-0.1.0.dist-info/RECORD +74 -0
- stryx_cli-0.1.0.dist-info/WHEEL +5 -0
- stryx_cli-0.1.0.dist-info/entry_points.txt +2 -0
- stryx_cli-0.1.0.dist-info/licenses/LICENSE +190 -0
- stryx_cli-0.1.0.dist-info/top_level.txt +1 -0
stryx/scanners/fuzz.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""API fuzzer module.
|
|
2
|
+
|
|
3
|
+
Mutates parameters, headers, cookies, JSON bodies, and query strings
|
|
4
|
+
to test for type confusion, boundary values, integer overflow, missing
|
|
5
|
+
validation, invalid encodings, deeply nested JSON, and large payloads.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
13
|
+
|
|
14
|
+
from stryx.utils.evidence import Evidence, Finding, Severity
|
|
15
|
+
from stryx.utils.http_client import HttpClient
|
|
16
|
+
from stryx.utils.logging import get_logger
|
|
17
|
+
|
|
18
|
+
logger = get_logger("scanner.fuzz")
|
|
19
|
+
|
|
20
|
+
# Fuzz payloads for different mutation types
|
|
21
|
+
FUZZ_PAYLOADS = {
|
|
22
|
+
"integer": [
|
|
23
|
+
"0", "-1", "1", "999999999999", "2147483647", "2147483648",
|
|
24
|
+
"-2147483648", "-2147483649", "0x7FFFFFFF", "0x80000000",
|
|
25
|
+
"99999999999999999999", "1.5", "NaN", "Infinity",
|
|
26
|
+
],
|
|
27
|
+
"string": [
|
|
28
|
+
"", "a", "a" * 1000, "a" * 10000,
|
|
29
|
+
"'", "\"", "\\", "\\'", "\\\"",
|
|
30
|
+
"<script>alert(1)</script>",
|
|
31
|
+
"{{7*7}}", "${7*7}",
|
|
32
|
+
"%00", "%0a", "%0d%0a",
|
|
33
|
+
"true", "false", "null", "undefined",
|
|
34
|
+
"[]", "{}", "[[]]",
|
|
35
|
+
],
|
|
36
|
+
"boundary": [
|
|
37
|
+
"0", "-0", "+0", "0.0", "-0.0",
|
|
38
|
+
"1.7976931348623157E+308",
|
|
39
|
+
"4.9E-324",
|
|
40
|
+
"1e999", "-1e999",
|
|
41
|
+
"99999999999999999999999999",
|
|
42
|
+
],
|
|
43
|
+
"encoding": [
|
|
44
|
+
"%00", "%0a", "%0d", "%0d%0a",
|
|
45
|
+
"%ef%bb%bf", "%c0%80", "%e0%80%80",
|
|
46
|
+
"\\u0000", "\\n", "\\r",
|
|
47
|
+
"\x00", "\n", "\r\n",
|
|
48
|
+
],
|
|
49
|
+
"nested_json": [
|
|
50
|
+
json.dumps({"a": {"b": {"c": {"d": {"e": "f"}}}}}),
|
|
51
|
+
json.dumps({"a": [1, [2, [3, [4]]]]}),
|
|
52
|
+
json.dumps({"__proto__": {"admin": True}}),
|
|
53
|
+
json.dumps({"constructor": {"prototype": {"admin": True}}}),
|
|
54
|
+
],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FuzzScanner:
|
|
59
|
+
"""Scanner for API fuzzing and input validation."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, client: HttpClient):
|
|
62
|
+
self.client = client
|
|
63
|
+
|
|
64
|
+
async def scan(
|
|
65
|
+
self,
|
|
66
|
+
endpoints: list[str],
|
|
67
|
+
base_url: str,
|
|
68
|
+
tests: list[str] | None = None,
|
|
69
|
+
) -> list[Finding]:
|
|
70
|
+
"""Run fuzzing tests against endpoints.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
endpoints: List of endpoint URLs to test.
|
|
74
|
+
base_url: Base URL of the target.
|
|
75
|
+
tests: List of test types to run (None = all).
|
|
76
|
+
Valid: parameters, body, headers, cookies, multipart.
|
|
77
|
+
"""
|
|
78
|
+
findings: list[Finding] = []
|
|
79
|
+
logger.info("Running fuzz scanner")
|
|
80
|
+
|
|
81
|
+
all_tests = ["parameters", "body", "headers", "cookies", "multipart"]
|
|
82
|
+
active_tests = tests or all_tests
|
|
83
|
+
|
|
84
|
+
for endpoint in endpoints:
|
|
85
|
+
try:
|
|
86
|
+
if "parameters" in active_tests:
|
|
87
|
+
param_findings = await self._fuzz_parameters(endpoint)
|
|
88
|
+
findings.extend(param_findings)
|
|
89
|
+
|
|
90
|
+
if "body" in active_tests:
|
|
91
|
+
body_findings = await self._fuzz_body(endpoint)
|
|
92
|
+
findings.extend(body_findings)
|
|
93
|
+
|
|
94
|
+
if "headers" in active_tests:
|
|
95
|
+
header_findings = await self._fuzz_headers(endpoint)
|
|
96
|
+
findings.extend(header_findings)
|
|
97
|
+
|
|
98
|
+
if "cookies" in active_tests:
|
|
99
|
+
cookie_findings = await self._fuzz_cookies(endpoint)
|
|
100
|
+
findings.extend(cookie_findings)
|
|
101
|
+
|
|
102
|
+
if "multipart" in active_tests:
|
|
103
|
+
multipart_findings = await self._fuzz_multipart(endpoint)
|
|
104
|
+
findings.extend(multipart_findings)
|
|
105
|
+
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.debug(f"Fuzz error on {endpoint}: {e}")
|
|
108
|
+
|
|
109
|
+
logger.info(f"Fuzz scanner found {len(findings)} findings")
|
|
110
|
+
return findings
|
|
111
|
+
|
|
112
|
+
async def _fuzz_parameters(self, endpoint: str) -> list[Finding]:
|
|
113
|
+
"""Fuzz query parameters of an endpoint."""
|
|
114
|
+
findings: list[Finding] = []
|
|
115
|
+
parsed = urlparse(endpoint)
|
|
116
|
+
params = parse_qs(parsed.query)
|
|
117
|
+
|
|
118
|
+
if not params:
|
|
119
|
+
return findings
|
|
120
|
+
|
|
121
|
+
for param_name, param_values in params.items():
|
|
122
|
+
original_value = param_values[0] if param_values else ""
|
|
123
|
+
|
|
124
|
+
# Determine fuzz category based on original value
|
|
125
|
+
categories = _guess_param_type(original_value)
|
|
126
|
+
|
|
127
|
+
for category in categories:
|
|
128
|
+
for payload in FUZZ_PAYLOADS.get(category, [])[:8]:
|
|
129
|
+
try:
|
|
130
|
+
test_params = dict(params)
|
|
131
|
+
test_params[param_name] = [payload]
|
|
132
|
+
test_url = (
|
|
133
|
+
f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
|
|
134
|
+
f"?{urlencode(test_params, doseq=True)}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
response, evidence = await self.client.get(test_url)
|
|
138
|
+
|
|
139
|
+
# Check for error-based indicators
|
|
140
|
+
if self._detect_fuzz_issue(response, payload, evidence):
|
|
141
|
+
evidence.confidence = 0.6
|
|
142
|
+
evidence.payload = payload
|
|
143
|
+
findings.append(Finding(
|
|
144
|
+
title=f"Fuzzing issue in parameter '{param_name}' ({category})",
|
|
145
|
+
severity=Severity.MEDIUM,
|
|
146
|
+
evidence=evidence,
|
|
147
|
+
description=(
|
|
148
|
+
f"Fuzzing parameter '{param_name}' with {category} "
|
|
149
|
+
f"payload caused an unexpected response."
|
|
150
|
+
),
|
|
151
|
+
remediation="Validate and sanitize all input parameters.",
|
|
152
|
+
cwe="CWE-20",
|
|
153
|
+
owasp="A03:2021 - Injection",
|
|
154
|
+
scanner="fuzz",
|
|
155
|
+
tags=["fuzzing", "input-validation"],
|
|
156
|
+
))
|
|
157
|
+
break # One finding per category per param
|
|
158
|
+
except Exception:
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
return findings
|
|
162
|
+
|
|
163
|
+
async def _fuzz_body(self, endpoint: str) -> list[Finding]:
|
|
164
|
+
"""Fuzz the request body."""
|
|
165
|
+
findings: list[Finding] = []
|
|
166
|
+
|
|
167
|
+
# Try nested JSON
|
|
168
|
+
for payload in FUZZ_PAYLOADS["nested_json"][:3]:
|
|
169
|
+
try:
|
|
170
|
+
response, evidence = await self.client.post(
|
|
171
|
+
endpoint,
|
|
172
|
+
body=payload,
|
|
173
|
+
headers={"Content-Type": "application/json"},
|
|
174
|
+
)
|
|
175
|
+
if self._detect_fuzz_issue(response, payload, evidence):
|
|
176
|
+
evidence.confidence = 0.5
|
|
177
|
+
evidence.payload = payload
|
|
178
|
+
findings.append(Finding(
|
|
179
|
+
title="Potential prototype pollution or nested JSON issue",
|
|
180
|
+
severity=Severity.MEDIUM,
|
|
181
|
+
evidence=evidence,
|
|
182
|
+
description="Deeply nested or prototype-polluting JSON payload caused an unexpected response.",
|
|
183
|
+
remediation="Validate JSON structure depth and reject prototype-polluting keys.",
|
|
184
|
+
cwe="CWE-1321",
|
|
185
|
+
owasp="A03:2021 - Injection",
|
|
186
|
+
scanner="fuzz",
|
|
187
|
+
tags=["fuzzing", "prototype-pollution"],
|
|
188
|
+
))
|
|
189
|
+
break
|
|
190
|
+
except Exception:
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
return findings
|
|
194
|
+
|
|
195
|
+
def _detect_fuzz_issue(
|
|
196
|
+
self, response: Any, payload: str, evidence: Evidence
|
|
197
|
+
) -> bool:
|
|
198
|
+
"""Detect if a fuzzing payload caused an issue."""
|
|
199
|
+
status = response.status_code
|
|
200
|
+
body = response.text.lower() if hasattr(response, "text") else ""
|
|
201
|
+
|
|
202
|
+
# Error indicators
|
|
203
|
+
error_patterns = [
|
|
204
|
+
"traceback", "exception", "error",
|
|
205
|
+
"internal server error", "500",
|
|
206
|
+
"stack trace", "debug",
|
|
207
|
+
"unhandled", "panic",
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
if status >= 500:
|
|
211
|
+
return True
|
|
212
|
+
|
|
213
|
+
return any(p in body for p in error_patterns)
|
|
214
|
+
|
|
215
|
+
async def _fuzz_headers(self, endpoint: str) -> list[Finding]:
|
|
216
|
+
"""Fuzz HTTP headers for injection and bypass."""
|
|
217
|
+
findings: list[Finding] = []
|
|
218
|
+
|
|
219
|
+
# Headers to fuzz
|
|
220
|
+
fuzz_headers = [
|
|
221
|
+
"User-Agent", "X-Forwarded-For", "X-Real-IP",
|
|
222
|
+
"X-Original-URL", "X-Rewrite-URL", "X-Custom-IP-Authorization",
|
|
223
|
+
"X-Forwarded-Host", "X-Host", "X-Remote-Addr",
|
|
224
|
+
"Content-Type", "Accept", "Authorization",
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
# Payloads for header fuzzing
|
|
228
|
+
header_payloads = [
|
|
229
|
+
"<script>alert(1)</script>",
|
|
230
|
+
"{{7*7}}",
|
|
231
|
+
"' OR '1'='1",
|
|
232
|
+
"../../etc/passwd",
|
|
233
|
+
"A" * 1000, # Long header
|
|
234
|
+
"%0d%0a%0d%0a", # CRLF injection
|
|
235
|
+
"\x00", # Null byte
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
for header_name in fuzz_headers:
|
|
239
|
+
for payload in header_payloads[:5]: # Limit payloads
|
|
240
|
+
try:
|
|
241
|
+
response, evidence = await self.client.get(
|
|
242
|
+
endpoint,
|
|
243
|
+
headers={header_name: payload},
|
|
244
|
+
)
|
|
245
|
+
if self._detect_fuzz_issue(response, payload, evidence):
|
|
246
|
+
evidence.confidence = 0.6
|
|
247
|
+
evidence.payload = payload
|
|
248
|
+
findings.append(Finding(
|
|
249
|
+
title=f"Fuzzing issue in header '{header_name}'",
|
|
250
|
+
severity=Severity.MEDIUM,
|
|
251
|
+
evidence=evidence,
|
|
252
|
+
description=(
|
|
253
|
+
f"Fuzzing header '{header_name}' with payload "
|
|
254
|
+
f"caused an unexpected response."
|
|
255
|
+
),
|
|
256
|
+
remediation="Validate and sanitize all header values.",
|
|
257
|
+
cwe="CWE-20",
|
|
258
|
+
owasp="A03:2021 - Injection",
|
|
259
|
+
scanner="fuzz",
|
|
260
|
+
tags=["fuzzing", "header-injection"],
|
|
261
|
+
))
|
|
262
|
+
break # One finding per header
|
|
263
|
+
except Exception:
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
return findings
|
|
267
|
+
|
|
268
|
+
async def _fuzz_cookies(self, endpoint: str) -> list[Finding]:
|
|
269
|
+
"""Fuzz cookies for injection and session manipulation."""
|
|
270
|
+
findings: list[Finding] = []
|
|
271
|
+
|
|
272
|
+
# Cookie names to fuzz
|
|
273
|
+
cookie_names = [
|
|
274
|
+
"session_id", "token", "user_id", "role", "admin",
|
|
275
|
+
"debug", "test", "language", "theme",
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
# Payloads for cookie fuzzing
|
|
279
|
+
cookie_payloads = [
|
|
280
|
+
"admin",
|
|
281
|
+
"true",
|
|
282
|
+
"1",
|
|
283
|
+
"<script>alert(1)</script>",
|
|
284
|
+
"' OR '1'='1",
|
|
285
|
+
"../../etc/passwd",
|
|
286
|
+
"A" * 500, # Long cookie
|
|
287
|
+
"%0d%0a", # CRLF injection
|
|
288
|
+
]
|
|
289
|
+
|
|
290
|
+
for cookie_name in cookie_names:
|
|
291
|
+
for payload in cookie_payloads[:5]:
|
|
292
|
+
try:
|
|
293
|
+
response, evidence = await self.client.get(
|
|
294
|
+
endpoint,
|
|
295
|
+
cookies={cookie_name: payload},
|
|
296
|
+
)
|
|
297
|
+
if self._detect_fuzz_issue(response, payload, evidence):
|
|
298
|
+
evidence.confidence = 0.6
|
|
299
|
+
evidence.payload = f"{cookie_name}={payload}"
|
|
300
|
+
findings.append(Finding(
|
|
301
|
+
title=f"Fuzzing issue in cookie '{cookie_name}'",
|
|
302
|
+
severity=Severity.MEDIUM,
|
|
303
|
+
evidence=evidence,
|
|
304
|
+
description=(
|
|
305
|
+
f"Fuzzing cookie '{cookie_name}' with payload "
|
|
306
|
+
f"caused an unexpected response."
|
|
307
|
+
),
|
|
308
|
+
remediation="Validate and sanitize all cookie values.",
|
|
309
|
+
cwe="CWE-20",
|
|
310
|
+
owasp="A03:2021 - Injection",
|
|
311
|
+
scanner="fuzz",
|
|
312
|
+
tags=["fuzzing", "cookie-injection"],
|
|
313
|
+
))
|
|
314
|
+
break # One finding per cookie
|
|
315
|
+
except Exception:
|
|
316
|
+
continue
|
|
317
|
+
|
|
318
|
+
return findings
|
|
319
|
+
|
|
320
|
+
async def _fuzz_multipart(self, endpoint: str) -> list[Finding]:
|
|
321
|
+
"""Fuzz multipart form-data uploads."""
|
|
322
|
+
findings: list[Finding] = []
|
|
323
|
+
|
|
324
|
+
# Payloads for multipart fuzzing
|
|
325
|
+
multipart_payloads = [
|
|
326
|
+
# Normal file with XSS content
|
|
327
|
+
("test.txt", b"<script>alert(1)</script>"),
|
|
328
|
+
# Oversized filename
|
|
329
|
+
("A" * 1000 + ".txt", b"test"),
|
|
330
|
+
# Path traversal in filename
|
|
331
|
+
("../../etc/passwd", b"test"),
|
|
332
|
+
# Null byte in filename
|
|
333
|
+
("test\x00.txt", b"test"),
|
|
334
|
+
# Deeply nested JSON
|
|
335
|
+
("test.json", b'{"a":{"b":{"c":{"d":"e"}}}}'),
|
|
336
|
+
# Prototype pollution
|
|
337
|
+
("test.json", b'{"__proto__":{"admin":true}}'),
|
|
338
|
+
]
|
|
339
|
+
|
|
340
|
+
for filename, content in multipart_payloads:
|
|
341
|
+
try:
|
|
342
|
+
# Build multipart form-data manually
|
|
343
|
+
body = (
|
|
344
|
+
f"------WebKitFormBoundary7MA4YWxkTrZu0gW\r\n"
|
|
345
|
+
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
|
|
346
|
+
f"Content-Type: application/octet-stream\r\n\r\n"
|
|
347
|
+
).encode() + content + b"\r\n------WebKitFormBoundary7MA4YWxkTrZu0gW--\r\n"
|
|
348
|
+
|
|
349
|
+
response, evidence = await self.client.post(
|
|
350
|
+
endpoint,
|
|
351
|
+
body=body.decode("latin-1"),
|
|
352
|
+
headers={
|
|
353
|
+
"Content-Type": "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW",
|
|
354
|
+
},
|
|
355
|
+
)
|
|
356
|
+
if self._detect_fuzz_issue(response, filename, evidence):
|
|
357
|
+
evidence.confidence = 0.5
|
|
358
|
+
evidence.payload = f"filename={filename}"
|
|
359
|
+
findings.append(Finding(
|
|
360
|
+
title=f"Fuzzing issue in multipart upload: {filename}",
|
|
361
|
+
severity=Severity.MEDIUM,
|
|
362
|
+
evidence=evidence,
|
|
363
|
+
description=(
|
|
364
|
+
f"Multipart upload with filename '{filename}' "
|
|
365
|
+
f"caused an unexpected response."
|
|
366
|
+
),
|
|
367
|
+
remediation="Validate and sanitize file uploads.",
|
|
368
|
+
cwe="CWE-20",
|
|
369
|
+
owasp="A03:2021 - Injection",
|
|
370
|
+
scanner="fuzz",
|
|
371
|
+
tags=["fuzzing", "multipart", "file-upload"],
|
|
372
|
+
))
|
|
373
|
+
break # One finding for multipart
|
|
374
|
+
except Exception:
|
|
375
|
+
continue
|
|
376
|
+
|
|
377
|
+
return findings
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _guess_param_type(value: str) -> list[str]:
|
|
381
|
+
"""Guess the type of a parameter value."""
|
|
382
|
+
categories = ["string"]
|
|
383
|
+
|
|
384
|
+
if value.isdigit() or (value.startswith("-") and value[1:].isdigit()):
|
|
385
|
+
categories.append("integer")
|
|
386
|
+
categories.append("boundary")
|
|
387
|
+
elif value.replace(".", "").replace("-", "").isdigit():
|
|
388
|
+
categories.append("boundary")
|
|
389
|
+
|
|
390
|
+
categories.extend(["encoding", "nested_json"])
|
|
391
|
+
return categories
|