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/auth.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""Authentication vulnerability scanner.
|
|
2
|
+
|
|
3
|
+
Tests: missing authentication, weak JWT validation, expired JWT acceptance,
|
|
4
|
+
unsigned JWTs, session fixation, cookie security, OAuth/provider misconfiguration.
|
|
5
|
+
Detects: JWT, OAuth2, Clerk, Supabase, Firebase, Auth0, Better Auth, session cookies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from stryx.utils.evidence import Finding, Severity
|
|
16
|
+
from stryx.utils.http_client import HttpClient
|
|
17
|
+
from stryx.utils.logging import get_logger
|
|
18
|
+
|
|
19
|
+
logger = get_logger("scanner.auth")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# --- Provider fingerprinting ---
|
|
23
|
+
|
|
24
|
+
PROVIDER_FINGERPRINTS = {
|
|
25
|
+
"jwt": {
|
|
26
|
+
"headers": ["authorization"],
|
|
27
|
+
"header_patterns": [r"Bearer\s+eyJ"],
|
|
28
|
+
"cookie_patterns": [r"token=", r"jwt=", r"access_token="],
|
|
29
|
+
"response_patterns": [r"jwt", r"bearer", r"token"],
|
|
30
|
+
},
|
|
31
|
+
"oauth2": {
|
|
32
|
+
"headers": ["x-oauth-token", "x-auth-token"],
|
|
33
|
+
"header_patterns": [r"Bearer"],
|
|
34
|
+
"cookie_patterns": [r"oauth", r"_oauth2", r"gsession"],
|
|
35
|
+
"response_patterns": [r"oauth", r"authorize", r"redirect_uri"],
|
|
36
|
+
},
|
|
37
|
+
"clerk": {
|
|
38
|
+
"headers": ["x-clerk-auth-status", "x-clerk-user-id"],
|
|
39
|
+
"header_patterns": [],
|
|
40
|
+
"cookie_patterns": [r"__session", r"__client"],
|
|
41
|
+
"response_patterns": [r"clerk", r"clerk\.publishable"],
|
|
42
|
+
},
|
|
43
|
+
"supabase": {
|
|
44
|
+
"headers": ["x-supabase-auth"],
|
|
45
|
+
"header_patterns": [r"supabase"],
|
|
46
|
+
"cookie_patterns": [r"sb-", r"supabase"],
|
|
47
|
+
"response_patterns": [r"supabase", r"supabase\.co"],
|
|
48
|
+
},
|
|
49
|
+
"firebase": {
|
|
50
|
+
"headers": ["x-firebase-auth"],
|
|
51
|
+
"header_patterns": [],
|
|
52
|
+
"cookie_patterns": [r"__session", r"firebase"],
|
|
53
|
+
"response_patterns": [r"firebase", r"firebaseio\.com", r"firebaseapp"],
|
|
54
|
+
},
|
|
55
|
+
"auth0": {
|
|
56
|
+
"headers": ["x-auth0-request-id"],
|
|
57
|
+
"header_patterns": [r"auth0"],
|
|
58
|
+
"cookie_patterns": [r"auth0", r"_auth0"],
|
|
59
|
+
"response_patterns": [r"auth0\.", r"auth0\.com"],
|
|
60
|
+
},
|
|
61
|
+
"better_auth": {
|
|
62
|
+
"headers": ["x-better-auth"],
|
|
63
|
+
"header_patterns": [r"better-auth"],
|
|
64
|
+
"cookie_patterns": [r"better-auth", r"ba_"],
|
|
65
|
+
"response_patterns": [r"better-auth", r"better\.auth"],
|
|
66
|
+
},
|
|
67
|
+
"session_cookie": {
|
|
68
|
+
"headers": [],
|
|
69
|
+
"header_patterns": [],
|
|
70
|
+
"cookie_patterns": [
|
|
71
|
+
r"session", r"sid", r"connect\.sid", r"JSESSIONID",
|
|
72
|
+
r"_session_id", r"laravel_session", r"csrftoken",
|
|
73
|
+
],
|
|
74
|
+
"response_patterns": [],
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def detect_auth_providers(
|
|
80
|
+
response_headers: dict[str, str],
|
|
81
|
+
response_cookies: dict[str, str],
|
|
82
|
+
response_body: str = "",
|
|
83
|
+
) -> list[str]:
|
|
84
|
+
"""Detect authentication providers from response headers, cookies, and body.
|
|
85
|
+
|
|
86
|
+
Returns list of detected provider names.
|
|
87
|
+
"""
|
|
88
|
+
detected = []
|
|
89
|
+
header_str = json.dumps(response_headers).lower()
|
|
90
|
+
cookie_str = json.dumps(response_cookies).lower()
|
|
91
|
+
body_lower = response_body.lower()
|
|
92
|
+
|
|
93
|
+
for provider, fp in PROVIDER_FINGERPRINTS.items():
|
|
94
|
+
found = False
|
|
95
|
+
|
|
96
|
+
# Check response headers
|
|
97
|
+
for h in fp["headers"]:
|
|
98
|
+
if h in header_str:
|
|
99
|
+
found = True
|
|
100
|
+
break
|
|
101
|
+
|
|
102
|
+
# Check header patterns
|
|
103
|
+
for pattern in fp["header_patterns"]:
|
|
104
|
+
if re.search(pattern, header_str, re.IGNORECASE):
|
|
105
|
+
found = True
|
|
106
|
+
break
|
|
107
|
+
|
|
108
|
+
# Check cookie patterns
|
|
109
|
+
for pattern in fp["cookie_patterns"]:
|
|
110
|
+
if re.search(pattern, cookie_str, re.IGNORECASE):
|
|
111
|
+
found = True
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
# Check response body patterns
|
|
115
|
+
for pattern in fp["response_patterns"]:
|
|
116
|
+
if re.search(pattern, body_lower, re.IGNORECASE):
|
|
117
|
+
found = True
|
|
118
|
+
break
|
|
119
|
+
|
|
120
|
+
if found:
|
|
121
|
+
detected.append(provider)
|
|
122
|
+
|
|
123
|
+
return detected
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class AuthScanner:
|
|
127
|
+
"""Scanner for authentication vulnerabilities."""
|
|
128
|
+
|
|
129
|
+
def __init__(self, client: HttpClient):
|
|
130
|
+
self.client = client
|
|
131
|
+
self.detected_providers: list[str] = []
|
|
132
|
+
|
|
133
|
+
async def scan(
|
|
134
|
+
self, endpoints: list[str], base_url: str
|
|
135
|
+
) -> list[Finding]:
|
|
136
|
+
"""Run authentication tests against the target."""
|
|
137
|
+
findings: list[Finding] = []
|
|
138
|
+
logger.info("Running authentication scanner")
|
|
139
|
+
|
|
140
|
+
# Stage 1: Fingerprint providers from a real response
|
|
141
|
+
await self._fingerprint_providers(base_url)
|
|
142
|
+
|
|
143
|
+
# Stage 2: Test unauthenticated access to protected endpoints
|
|
144
|
+
findings.extend(await self._test_missing_auth(endpoints, base_url))
|
|
145
|
+
|
|
146
|
+
# Stage 3: Test JWT weaknesses based on detected provider
|
|
147
|
+
findings.extend(await self._test_jwt_weaknesses(endpoints, base_url))
|
|
148
|
+
|
|
149
|
+
# Stage 4: Test session fixation
|
|
150
|
+
findings.extend(await self._test_session_fixation(base_url))
|
|
151
|
+
|
|
152
|
+
# Stage 5: Test cookie security
|
|
153
|
+
findings.extend(await self._test_cookie_security(base_url))
|
|
154
|
+
|
|
155
|
+
logger.info(f"Auth scanner found {len(findings)} findings")
|
|
156
|
+
return findings
|
|
157
|
+
|
|
158
|
+
async def _fingerprint_providers(self, base_url: str) -> None:
|
|
159
|
+
"""Fingerprint authentication providers from the target."""
|
|
160
|
+
try:
|
|
161
|
+
response, evidence = await self.client.get(base_url)
|
|
162
|
+
self.detected_providers = detect_auth_providers(
|
|
163
|
+
dict(response.headers),
|
|
164
|
+
dict(response.cookies),
|
|
165
|
+
response.text,
|
|
166
|
+
)
|
|
167
|
+
if self.detected_providers:
|
|
168
|
+
logger.info(
|
|
169
|
+
f"Detected auth providers: {', '.join(self.detected_providers)}"
|
|
170
|
+
)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
logger.debug(f"Provider fingerprinting failed: {e}")
|
|
173
|
+
|
|
174
|
+
async def _test_missing_auth(
|
|
175
|
+
self, endpoints: list[str], base_url: str
|
|
176
|
+
) -> list[Finding]:
|
|
177
|
+
"""Test if endpoints are accessible without authentication."""
|
|
178
|
+
findings: list[Finding] = []
|
|
179
|
+
|
|
180
|
+
protected_paths = [
|
|
181
|
+
"/admin", "/admin/", "/api/admin", "/dashboard",
|
|
182
|
+
"/api/users", "/api/user", "/api/profile",
|
|
183
|
+
"/settings", "/api/settings", "/api/config",
|
|
184
|
+
"/api/internal", "/internal", "/debug",
|
|
185
|
+
"/api/me", "/api/account", "/api/account/settings",
|
|
186
|
+
"/api/billing", "/api/keys", "/api/secrets",
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
for path in protected_paths:
|
|
190
|
+
url = f"{base_url}{path}"
|
|
191
|
+
try:
|
|
192
|
+
response, evidence = await self.client.get(url)
|
|
193
|
+
if response.status_code == 200:
|
|
194
|
+
body_lower = response.text.lower()
|
|
195
|
+
# Confirm it's actual content, not a redirect or error page
|
|
196
|
+
if not any(x in body_lower for x in [
|
|
197
|
+
"login", "sign in", "unauthorized", "forbidden",
|
|
198
|
+
]):
|
|
199
|
+
evidence.confidence = 0.7
|
|
200
|
+
findings.append(Finding(
|
|
201
|
+
title=f"Unauthenticated access to {path}",
|
|
202
|
+
severity=Severity.HIGH,
|
|
203
|
+
evidence=evidence,
|
|
204
|
+
description=(
|
|
205
|
+
f"The endpoint {path} is accessible without any "
|
|
206
|
+
f"authentication. This may expose sensitive data "
|
|
207
|
+
f"or administrative functions."
|
|
208
|
+
),
|
|
209
|
+
remediation="Implement authentication middleware to protect this endpoint.",
|
|
210
|
+
cwe="CWE-306",
|
|
211
|
+
owasp="A07:2021 - Identification and Authentication Failures",
|
|
212
|
+
scanner="auth",
|
|
213
|
+
tags=["missing-auth", "no-auth"],
|
|
214
|
+
))
|
|
215
|
+
except Exception:
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
return findings
|
|
219
|
+
|
|
220
|
+
async def _test_jwt_weaknesses(
|
|
221
|
+
self, endpoints: list[str], base_url: str
|
|
222
|
+
) -> list[Finding]:
|
|
223
|
+
"""Test for JWT implementation weaknesses.
|
|
224
|
+
|
|
225
|
+
Tests: empty JWT, unsigned JWT, algorithm none, expired JWT,
|
|
226
|
+
malformed token, missing signature.
|
|
227
|
+
"""
|
|
228
|
+
findings: list[Finding] = []
|
|
229
|
+
|
|
230
|
+
# Build test JWTs
|
|
231
|
+
test_jwts = _build_jwt_test_cases()
|
|
232
|
+
|
|
233
|
+
for jwt_header, jwt_desc in test_jwts:
|
|
234
|
+
for endpoint in endpoints[:15]:
|
|
235
|
+
try:
|
|
236
|
+
headers = {"Authorization": f"Bearer {jwt_header}"} if jwt_header else {}
|
|
237
|
+
response, evidence = await self.client.get(endpoint, headers=headers)
|
|
238
|
+
if response.status_code == 200 and jwt_header:
|
|
239
|
+
evidence.confidence = 0.8
|
|
240
|
+
findings.append(Finding(
|
|
241
|
+
title=f"Weak JWT accepted: {jwt_desc} at {endpoint}",
|
|
242
|
+
severity=Severity.CRITICAL,
|
|
243
|
+
evidence=evidence,
|
|
244
|
+
description=(
|
|
245
|
+
f"The endpoint accepted {jwt_desc}. "
|
|
246
|
+
f"This indicates weak token validation that could "
|
|
247
|
+
f"allow authentication bypass."
|
|
248
|
+
),
|
|
249
|
+
remediation=(
|
|
250
|
+
"Validate JWT signatures, algorithm, expiration. "
|
|
251
|
+
"Reject 'none' algorithm. Enforce signature verification."
|
|
252
|
+
),
|
|
253
|
+
cwe="CWE-347",
|
|
254
|
+
owasp="A07:2021 - Identification and Authentication Failures",
|
|
255
|
+
scanner="auth",
|
|
256
|
+
tags=["jwt", "weak-auth", "jwt-weakness"],
|
|
257
|
+
))
|
|
258
|
+
break # One finding per JWT type
|
|
259
|
+
except Exception:
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
return findings
|
|
263
|
+
|
|
264
|
+
async def _test_session_fixation(self, base_url: str) -> list[Finding]:
|
|
265
|
+
"""Test for session fixation vulnerabilities.
|
|
266
|
+
|
|
267
|
+
Checks if session cookie changes after login attempt.
|
|
268
|
+
"""
|
|
269
|
+
findings: list[Finding] = []
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
# Make initial request to get session
|
|
273
|
+
response1, _ = await self.client.get(f"{base_url}/")
|
|
274
|
+
if response1.status_code != 200:
|
|
275
|
+
return findings
|
|
276
|
+
|
|
277
|
+
session_cookies = dict(response1.cookies)
|
|
278
|
+
if not session_cookies:
|
|
279
|
+
return findings
|
|
280
|
+
|
|
281
|
+
# Make login request keeping the same session cookie
|
|
282
|
+
response2, evidence = await self.client.post(
|
|
283
|
+
f"{base_url}/login",
|
|
284
|
+
json_data={"username": "test", "password": "test"},
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
new_cookies = dict(response2.cookies)
|
|
288
|
+
# Check if session cookie was NOT regenerated
|
|
289
|
+
for cookie_name in session_cookies:
|
|
290
|
+
if cookie_name in new_cookies:
|
|
291
|
+
if new_cookies[cookie_name] == session_cookies[cookie_name]:
|
|
292
|
+
evidence.confidence = 0.6
|
|
293
|
+
findings.append(Finding(
|
|
294
|
+
title="Potential session fixation",
|
|
295
|
+
severity=Severity.MEDIUM,
|
|
296
|
+
evidence=evidence,
|
|
297
|
+
description=(
|
|
298
|
+
f"The session cookie '{cookie_name}' did not change "
|
|
299
|
+
f"after login, which may indicate session fixation. "
|
|
300
|
+
f"An attacker could set a known session ID before the "
|
|
301
|
+
f"user authenticates."
|
|
302
|
+
),
|
|
303
|
+
remediation="Regenerate session ID after successful authentication.",
|
|
304
|
+
cwe="CWE-384",
|
|
305
|
+
owasp="A07:2021 - Identification and Authentication Failures",
|
|
306
|
+
scanner="auth",
|
|
307
|
+
tags=["session-fixation"],
|
|
308
|
+
))
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
|
|
312
|
+
return findings
|
|
313
|
+
|
|
314
|
+
async def _test_cookie_security(self, base_url: str) -> list[Finding]:
|
|
315
|
+
"""Test for insecure cookie configurations."""
|
|
316
|
+
findings: list[Finding] = []
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
response, evidence = await self.client.get(base_url)
|
|
320
|
+
set_cookie_headers = []
|
|
321
|
+
for key, value in response.headers.items():
|
|
322
|
+
if key.lower() == "set-cookie":
|
|
323
|
+
set_cookie_headers.append(value)
|
|
324
|
+
|
|
325
|
+
if not set_cookie_headers:
|
|
326
|
+
return findings
|
|
327
|
+
|
|
328
|
+
for cookie_header in set_cookie_headers:
|
|
329
|
+
cookie_lower = cookie_header.lower()
|
|
330
|
+
|
|
331
|
+
# Missing Secure flag
|
|
332
|
+
if "secure" not in cookie_lower:
|
|
333
|
+
evidence.confidence = 0.6
|
|
334
|
+
findings.append(Finding(
|
|
335
|
+
title="Cookie missing Secure flag",
|
|
336
|
+
severity=Severity.MEDIUM,
|
|
337
|
+
evidence=evidence,
|
|
338
|
+
description=(
|
|
339
|
+
f"Cookie '{cookie_header[:60]}...' is set without "
|
|
340
|
+
f"the Secure flag, allowing it to be sent over HTTP."
|
|
341
|
+
),
|
|
342
|
+
remediation="Set the Secure flag on all session cookies.",
|
|
343
|
+
cwe="CWE-614",
|
|
344
|
+
owasp="A05:2021 - Security Misconfiguration",
|
|
345
|
+
scanner="auth",
|
|
346
|
+
tags=["cookie", "insecure-flag"],
|
|
347
|
+
))
|
|
348
|
+
break # One finding per type
|
|
349
|
+
|
|
350
|
+
# Missing HttpOnly flag
|
|
351
|
+
if "httponly" not in cookie_lower:
|
|
352
|
+
evidence.confidence = 0.6
|
|
353
|
+
findings.append(Finding(
|
|
354
|
+
title="Cookie missing HttpOnly flag",
|
|
355
|
+
severity=Severity.MEDIUM,
|
|
356
|
+
evidence=evidence,
|
|
357
|
+
description=(
|
|
358
|
+
f"Cookie '{cookie_header[:60]}...' is set without "
|
|
359
|
+
f"the HttpOnly flag, exposing it to XSS attacks."
|
|
360
|
+
),
|
|
361
|
+
remediation="Set the HttpOnly flag on all session cookies.",
|
|
362
|
+
cwe="CWE-1004",
|
|
363
|
+
owasp="A05:2021 - Security Misconfiguration",
|
|
364
|
+
scanner="auth",
|
|
365
|
+
tags=["cookie", "httponly"],
|
|
366
|
+
))
|
|
367
|
+
break
|
|
368
|
+
|
|
369
|
+
# Missing SameSite attribute
|
|
370
|
+
if "samesite" not in cookie_lower:
|
|
371
|
+
evidence.confidence = 0.5
|
|
372
|
+
findings.append(Finding(
|
|
373
|
+
title="Cookie missing SameSite attribute",
|
|
374
|
+
severity=Severity.LOW,
|
|
375
|
+
evidence=evidence,
|
|
376
|
+
description=(
|
|
377
|
+
f"Cookie '{cookie_header[:60]}...' is set without "
|
|
378
|
+
f"the SameSite attribute."
|
|
379
|
+
),
|
|
380
|
+
remediation="Set SameSite=Strict or SameSite=Lax on cookies.",
|
|
381
|
+
cwe="CWE-1275",
|
|
382
|
+
owasp="A05:2021 - Security Misconfiguration",
|
|
383
|
+
scanner="auth",
|
|
384
|
+
tags=["cookie", "samesite"],
|
|
385
|
+
))
|
|
386
|
+
break
|
|
387
|
+
|
|
388
|
+
except Exception:
|
|
389
|
+
pass
|
|
390
|
+
|
|
391
|
+
return findings
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _build_jwt_test_cases() -> list[tuple[str, str]]:
|
|
395
|
+
"""Build JWT test cases for weakness testing."""
|
|
396
|
+
cases = []
|
|
397
|
+
|
|
398
|
+
# 1. Empty JWT
|
|
399
|
+
cases.append(("", "empty Bearer token"))
|
|
400
|
+
|
|
401
|
+
# 2. Unsigned JWT (alg: none)
|
|
402
|
+
header_b64 = base64.urlsafe_b64encode(
|
|
403
|
+
json.dumps({"alg": "none", "typ": "JWT"}).encode()
|
|
404
|
+
).rstrip(b"=").decode()
|
|
405
|
+
payload_b64 = base64.urlsafe_b64encode(
|
|
406
|
+
json.dumps({"sub": "1", "name": "admin", "role": "admin"}).encode()
|
|
407
|
+
).rstrip(b"=").decode()
|
|
408
|
+
cases.append((f"{header_b64}.{payload_b64}.", "unsigned JWT (alg: none)"))
|
|
409
|
+
|
|
410
|
+
# 3. JWT with "none" algorithm variant
|
|
411
|
+
header_b64 = base64.urlsafe_b64encode(
|
|
412
|
+
json.dumps({"alg": "None", "typ": "JWT"}).encode()
|
|
413
|
+
).rstrip(b"=").decode()
|
|
414
|
+
cases.append((f"{header_b64}.{payload_b64}.", "JWT with None algorithm (mixed case)"))
|
|
415
|
+
|
|
416
|
+
# 4. Expired JWT
|
|
417
|
+
expired_payload = base64.urlsafe_b64encode(
|
|
418
|
+
json.dumps({
|
|
419
|
+
"sub": "1",
|
|
420
|
+
"exp": int(time.time()) - 3600, # 1 hour ago
|
|
421
|
+
"iat": int(time.time()) - 7200,
|
|
422
|
+
}).encode()
|
|
423
|
+
).rstrip(b"=").decode()
|
|
424
|
+
header_hs256 = base64.urlsafe_b64encode(
|
|
425
|
+
json.dumps({"alg": "HS256", "typ": "JWT"}).encode()
|
|
426
|
+
).rstrip(b"=").decode()
|
|
427
|
+
cases.append((
|
|
428
|
+
f"{header_hs256}.{expired_payload}.fake_signature",
|
|
429
|
+
"expired JWT",
|
|
430
|
+
))
|
|
431
|
+
|
|
432
|
+
# 5. JWT with no signature
|
|
433
|
+
cases.append((
|
|
434
|
+
f"{header_hs256}.{payload_b64}",
|
|
435
|
+
"JWT with missing signature",
|
|
436
|
+
))
|
|
437
|
+
|
|
438
|
+
# 6. Malformed token
|
|
439
|
+
cases.append(("not.a.valid.jwt.token", "malformed JWT token"))
|
|
440
|
+
|
|
441
|
+
# 7. Just "Bearer" with no token
|
|
442
|
+
cases.append(("Bearer", "Bearer keyword with no token"))
|
|
443
|
+
|
|
444
|
+
return cases
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Authorization vulnerability scanner.
|
|
2
|
+
|
|
3
|
+
Tests for IDOR, horizontal/vertical privilege escalation, multi-tenant escape,
|
|
4
|
+
admin endpoint access, and resource ownership bypass.
|
|
5
|
+
Uses the ReplayEngine for proper IDOR testing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from stryx.attacks.replay import ReplayEngine
|
|
11
|
+
from stryx.utils.evidence import Finding, Severity
|
|
12
|
+
from stryx.utils.http_client import HttpClient
|
|
13
|
+
from stryx.utils.logging import get_logger
|
|
14
|
+
|
|
15
|
+
logger = get_logger("scanner.authorization")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AuthorizationScanner:
|
|
19
|
+
"""Scanner for authorization vulnerabilities."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, client: HttpClient):
|
|
22
|
+
self.client = client
|
|
23
|
+
self.replay = ReplayEngine(client)
|
|
24
|
+
|
|
25
|
+
async def scan(
|
|
26
|
+
self, endpoints: list[str], base_url: str, cookies: str = ""
|
|
27
|
+
) -> list[Finding]:
|
|
28
|
+
"""Run authorization tests."""
|
|
29
|
+
findings: list[Finding] = []
|
|
30
|
+
logger.info("Running authorization scanner")
|
|
31
|
+
|
|
32
|
+
# Test IDOR on discovered endpoints with numeric IDs
|
|
33
|
+
findings.extend(await self._test_idor(endpoints, base_url, cookies))
|
|
34
|
+
|
|
35
|
+
# Test privilege escalation
|
|
36
|
+
findings.extend(await self._test_privilege_escalation(endpoints, base_url, cookies))
|
|
37
|
+
|
|
38
|
+
# Test admin access
|
|
39
|
+
findings.extend(await self._test_admin_access(base_url, cookies))
|
|
40
|
+
|
|
41
|
+
# Test horizontal privilege escalation
|
|
42
|
+
findings.extend(await self._test_horizontal_escalation(endpoints, base_url, cookies))
|
|
43
|
+
|
|
44
|
+
logger.info(f"Authorization scanner found {len(findings)} findings")
|
|
45
|
+
return findings
|
|
46
|
+
|
|
47
|
+
async def _test_idor(
|
|
48
|
+
self, endpoints: list[str], base_url: str, cookies: str
|
|
49
|
+
) -> list[Finding]:
|
|
50
|
+
"""Test for Insecure Direct Object Reference using replay engine."""
|
|
51
|
+
findings: list[Finding] = []
|
|
52
|
+
|
|
53
|
+
# Find endpoints that likely contain IDs
|
|
54
|
+
idor_candidates = [
|
|
55
|
+
ep for ep in endpoints
|
|
56
|
+
if ReplayEngine.is_idor_candidate(ep)
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
# If no IDOR candidates from endpoints, test common patterns
|
|
60
|
+
if not idor_candidates:
|
|
61
|
+
idor_candidates = [
|
|
62
|
+
f"{base_url}/api/users/1",
|
|
63
|
+
f"{base_url}/api/users/2",
|
|
64
|
+
f"{base_url}/api/orders/1",
|
|
65
|
+
f"{base_url}/api/orders/2",
|
|
66
|
+
f"{base_url}/api/documents/1",
|
|
67
|
+
f"{base_url}/api/documents/2",
|
|
68
|
+
f"{base_url}/users/1",
|
|
69
|
+
f"{base_url}/profile/1",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
cookie_dict = _parse_cookies(cookies)
|
|
73
|
+
|
|
74
|
+
for url in idor_candidates:
|
|
75
|
+
# Extract IDs from the URL
|
|
76
|
+
ids = ReplayEngine.extract_ids_from_url(url)
|
|
77
|
+
if not ids:
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
original_id = ids[0]
|
|
81
|
+
finding = await self.replay.test_idor(
|
|
82
|
+
url=url,
|
|
83
|
+
original_id=original_id,
|
|
84
|
+
headers={"Cookie": _format_cookies(cookie_dict)} if cookie_dict else None,
|
|
85
|
+
)
|
|
86
|
+
if finding:
|
|
87
|
+
findings.append(finding)
|
|
88
|
+
break # One IDOR finding is sufficient
|
|
89
|
+
|
|
90
|
+
return findings
|
|
91
|
+
|
|
92
|
+
async def _test_privilege_escalation(
|
|
93
|
+
self, endpoints: list[str], base_url: str, cookies: str
|
|
94
|
+
) -> list[Finding]:
|
|
95
|
+
"""Test for privilege escalation."""
|
|
96
|
+
findings: list[Finding] = []
|
|
97
|
+
cookie_dict = _parse_cookies(cookies)
|
|
98
|
+
|
|
99
|
+
# Test vertical escalation - accessing admin endpoints as regular user
|
|
100
|
+
admin_paths = [
|
|
101
|
+
"/admin", "/admin/", "/api/admin", "/api/admin/users",
|
|
102
|
+
"/api/admin/config", "/api/admin/settings",
|
|
103
|
+
"/api/admin/stats", "/api/admin/logs",
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
for path in admin_paths:
|
|
107
|
+
url = f"{base_url}{path}"
|
|
108
|
+
try:
|
|
109
|
+
response, evidence = await self.client.get(url, cookies=cookie_dict)
|
|
110
|
+
if response.status_code == 200:
|
|
111
|
+
evidence.confidence = 0.7
|
|
112
|
+
findings.append(Finding(
|
|
113
|
+
title=f"Admin endpoint accessible: {path}",
|
|
114
|
+
severity=Severity.CRITICAL,
|
|
115
|
+
evidence=evidence,
|
|
116
|
+
description=(
|
|
117
|
+
f"The admin endpoint {path} is accessible. "
|
|
118
|
+
f"This may allow privilege escalation."
|
|
119
|
+
),
|
|
120
|
+
remediation="Restrict admin endpoints to authorized administrators.",
|
|
121
|
+
cwe="CWE-269",
|
|
122
|
+
owasp="A01:2021 - Broken Access Control",
|
|
123
|
+
scanner="authorization",
|
|
124
|
+
tags=["privilege-escalation", "admin-access"],
|
|
125
|
+
))
|
|
126
|
+
except Exception:
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
return findings
|
|
130
|
+
|
|
131
|
+
async def _test_admin_access(
|
|
132
|
+
self, base_url: str, cookies: str
|
|
133
|
+
) -> list[Finding]:
|
|
134
|
+
"""Test for unauthorized admin access."""
|
|
135
|
+
findings: list[Finding] = []
|
|
136
|
+
|
|
137
|
+
# Try accessing admin with and without auth
|
|
138
|
+
admin_endpoints = [
|
|
139
|
+
"/admin", "/admin/dashboard", "/api/admin",
|
|
140
|
+
"/admin/panel", "/administrator",
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
for path in admin_endpoints:
|
|
144
|
+
# Without cookies
|
|
145
|
+
try:
|
|
146
|
+
url = f"{base_url}{path}"
|
|
147
|
+
response, evidence = await self.client.get(url)
|
|
148
|
+
if response.status_code == 200:
|
|
149
|
+
evidence.confidence = 0.8
|
|
150
|
+
findings.append(Finding(
|
|
151
|
+
title=f"Admin panel accessible without auth: {path}",
|
|
152
|
+
severity=Severity.CRITICAL,
|
|
153
|
+
evidence=evidence,
|
|
154
|
+
description=(
|
|
155
|
+
f"The admin panel at {path} is accessible without "
|
|
156
|
+
f"authentication. This is a critical security issue."
|
|
157
|
+
),
|
|
158
|
+
remediation=(
|
|
159
|
+
"Implement authentication and authorization "
|
|
160
|
+
"for admin interfaces."
|
|
161
|
+
),
|
|
162
|
+
cwe="CWE-284",
|
|
163
|
+
owasp="A01:2021 - Broken Access Control",
|
|
164
|
+
scanner="authorization",
|
|
165
|
+
tags=["admin-panel", "no-auth"],
|
|
166
|
+
))
|
|
167
|
+
except Exception:
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
return findings
|
|
171
|
+
|
|
172
|
+
async def _test_horizontal_escalation(
|
|
173
|
+
self, endpoints: list[str], base_url: str, cookies: str
|
|
174
|
+
) -> list[Finding]:
|
|
175
|
+
"""Test horizontal privilege escalation across user resources."""
|
|
176
|
+
findings: list[Finding] = []
|
|
177
|
+
cookie_dict = _parse_cookies(cookies)
|
|
178
|
+
|
|
179
|
+
# Find endpoints with {user_id} pattern or numeric IDs
|
|
180
|
+
user_endpoints = [
|
|
181
|
+
ep for ep in endpoints
|
|
182
|
+
if "{user_id}" in ep or "/users/" in ep or "/profile/" in ep
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
# Add common patterns if none found
|
|
186
|
+
if not user_endpoints:
|
|
187
|
+
user_endpoints = [
|
|
188
|
+
f"{base_url}/api/users/{{user_id}}",
|
|
189
|
+
f"{base_url}/api/profile/{{user_id}}",
|
|
190
|
+
f"{base_url}/users/{{user_id}}",
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
for url_template in user_endpoints:
|
|
194
|
+
finding = await self.replay.test_horizontal_escalation(
|
|
195
|
+
url=url_template,
|
|
196
|
+
user_ids=["1", "2", "3", "4", "5"],
|
|
197
|
+
headers={"Cookie": _format_cookies(cookie_dict)} if cookie_dict else None,
|
|
198
|
+
)
|
|
199
|
+
if finding:
|
|
200
|
+
findings.append(finding)
|
|
201
|
+
break # One finding is sufficient
|
|
202
|
+
|
|
203
|
+
return findings
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _parse_cookies(cookie_str: str) -> dict[str, str]:
|
|
207
|
+
"""Parse cookie string into dictionary."""
|
|
208
|
+
cookies: dict[str, str] = {}
|
|
209
|
+
if not cookie_str:
|
|
210
|
+
return cookies
|
|
211
|
+
for pair in cookie_str.split(";"):
|
|
212
|
+
if "=" in pair:
|
|
213
|
+
key, value = pair.split("=", 1)
|
|
214
|
+
cookies[key.strip()] = value.strip()
|
|
215
|
+
return cookies
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _format_cookies(cookie_dict: dict[str, str]) -> str:
|
|
219
|
+
"""Format cookie dictionary as Cookie header string."""
|
|
220
|
+
return "; ".join(f"{k}={v}" for k, v in cookie_dict.items())
|