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.
Files changed (74) hide show
  1. stryx/__init__.py +35 -0
  2. stryx/ai/__init__.py +5 -0
  3. stryx/ai/attack_planner.py +199 -0
  4. stryx/ai/payload_generator.py +212 -0
  5. stryx/ai/prompts.py +85 -0
  6. stryx/ai/providers.py +249 -0
  7. stryx/attacks/__init__.py +6 -0
  8. stryx/attacks/attack_chain.py +111 -0
  9. stryx/attacks/replay.py +186 -0
  10. stryx/auth/__init__.py +1 -0
  11. stryx/auth/session_manager.py +335 -0
  12. stryx/cli.py +675 -0
  13. stryx/comparison/__init__.py +1 -0
  14. stryx/comparison/differ.py +255 -0
  15. stryx/config/__init__.py +6 -0
  16. stryx/config/default_config.yaml +12 -0
  17. stryx/config/loader.py +111 -0
  18. stryx/config/schema.py +80 -0
  19. stryx/crawler/__init__.py +5 -0
  20. stryx/crawler/discovery.py +178 -0
  21. stryx/crawler/graphql_discovery.py +86 -0
  22. stryx/crawler/html_crawler.py +294 -0
  23. stryx/crawler/js_endpoints.py +165 -0
  24. stryx/crawler/openapi.py +76 -0
  25. stryx/crawler/sitemap.py +94 -0
  26. stryx/orchestrator.py +347 -0
  27. stryx/payloads/cmdi.txt +31 -0
  28. stryx/payloads/header_injection.txt +15 -0
  29. stryx/payloads/ldap.txt +19 -0
  30. stryx/payloads/nosqli.txt +21 -0
  31. stryx/payloads/open_redirect.txt +23 -0
  32. stryx/payloads/path_traversal.txt +26 -0
  33. stryx/payloads/sqli.txt +31 -0
  34. stryx/payloads/ssrf.txt +30 -0
  35. stryx/payloads/ssti.txt +21 -0
  36. stryx/payloads/xss.txt +48 -0
  37. stryx/payloads/xxe.txt +35 -0
  38. stryx/plugins/__init__.py +5 -0
  39. stryx/plugins/base.py +85 -0
  40. stryx/policy/__init__.py +1 -0
  41. stryx/policy/engine.py +201 -0
  42. stryx/py.typed +0 -0
  43. stryx/reports/__init__.py +5 -0
  44. stryx/reports/generator.py +122 -0
  45. stryx/reports/json_report.py +72 -0
  46. stryx/reports/markdown_report.py +101 -0
  47. stryx/reports/sarif_report.py +200 -0
  48. stryx/reports/templates/report.html.j2 +163 -0
  49. stryx/reports/terminal_report.py +138 -0
  50. stryx/scanners/__init__.py +17 -0
  51. stryx/scanners/auth.py +444 -0
  52. stryx/scanners/authorization.py +220 -0
  53. stryx/scanners/blind.py +328 -0
  54. stryx/scanners/cloud_ssrf.py +208 -0
  55. stryx/scanners/cors.py +158 -0
  56. stryx/scanners/dependencies.py +296 -0
  57. stryx/scanners/disclosure.py +339 -0
  58. stryx/scanners/fuzz.py +391 -0
  59. stryx/scanners/graphql.py +309 -0
  60. stryx/scanners/injection.py +511 -0
  61. stryx/scanners/race.py +257 -0
  62. stryx/signatures/framework_fingerprints.yaml +122 -0
  63. stryx/signatures/known_vulns.yaml +210 -0
  64. stryx/utils/__init__.py +7 -0
  65. stryx/utils/evidence.py +109 -0
  66. stryx/utils/http_client.py +159 -0
  67. stryx/utils/logging.py +51 -0
  68. stryx/utils/rate_limiter.py +37 -0
  69. stryx_cli-0.1.0.dist-info/METADATA +236 -0
  70. stryx_cli-0.1.0.dist-info/RECORD +74 -0
  71. stryx_cli-0.1.0.dist-info/WHEEL +5 -0
  72. stryx_cli-0.1.0.dist-info/entry_points.txt +2 -0
  73. stryx_cli-0.1.0.dist-info/licenses/LICENSE +190 -0
  74. 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())