offsec-ai 2.0.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.
- offsec_ai/__init__.py +91 -0
- offsec_ai/__main__.py +12 -0
- offsec_ai/cli.py +2764 -0
- offsec_ai/core/__init__.py +1 -0
- offsec_ai/core/ai_owasp_scanner.py +389 -0
- offsec_ai/core/cert_analyzer.py +721 -0
- offsec_ai/core/hybrid_identity_checker.py +585 -0
- offsec_ai/core/l7_detector.py +1628 -0
- offsec_ai/core/llm_judge.py +183 -0
- offsec_ai/core/mcp_attacker.py +384 -0
- offsec_ai/core/mcp_scanner.py +506 -0
- offsec_ai/core/mtls_checker.py +990 -0
- offsec_ai/core/owasp_scanner.py +653 -0
- offsec_ai/core/port_scanner.py +277 -0
- offsec_ai/core/security_headers.py +472 -0
- offsec_ai/models/__init__.py +1 -0
- offsec_ai/models/ai_owasp_result.py +161 -0
- offsec_ai/models/l7_result.py +231 -0
- offsec_ai/models/mcp_result.py +148 -0
- offsec_ai/models/mtls_result.py +95 -0
- offsec_ai/models/owasp_result.py +282 -0
- offsec_ai/models/scan_result.py +143 -0
- offsec_ai/py.typed +0 -0
- offsec_ai/utils/__init__.py +1 -0
- offsec_ai/utils/ai_owasp_payloads.py +283 -0
- offsec_ai/utils/ai_owasp_remediation.py +248 -0
- offsec_ai/utils/common_ports.py +316 -0
- offsec_ai/utils/exporters.py +441 -0
- offsec_ai/utils/l7_signatures.py +460 -0
- offsec_ai/utils/mcp_cve_db.py +263 -0
- offsec_ai/utils/mcp_payloads.py +121 -0
- offsec_ai/utils/owasp_remediation.py +787 -0
- offsec_ai-2.0.0.dist-info/METADATA +601 -0
- offsec_ai-2.0.0.dist-info/RECORD +37 -0
- offsec_ai-2.0.0.dist-info/WHEEL +4 -0
- offsec_ai-2.0.0.dist-info/entry_points.txt +2 -0
- offsec_ai-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP (Model Context Protocol) endpoint security scanner.
|
|
3
|
+
|
|
4
|
+
Connects to an MCP server via HTTP/SSE or stdio, enumerates capabilities,
|
|
5
|
+
fingerprints the server, checks authentication posture, and matches against
|
|
6
|
+
known CVEs and misconfigurations.
|
|
7
|
+
|
|
8
|
+
Usage (HTTP):
|
|
9
|
+
scanner = MCPScanner("https://mcp.example.com/mcp")
|
|
10
|
+
result = await scanner.scan()
|
|
11
|
+
|
|
12
|
+
Usage (stdio):
|
|
13
|
+
scanner = MCPScanner("stdio://localhost", transport="stdio", cmd=["python", "server.py"])
|
|
14
|
+
result = await scanner.scan()
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import json
|
|
21
|
+
import shlex
|
|
22
|
+
import subprocess
|
|
23
|
+
import time
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
from ..models.mcp_result import (
|
|
29
|
+
MCPAuthPosture,
|
|
30
|
+
MCPPrompt,
|
|
31
|
+
MCPResource,
|
|
32
|
+
MCPScanResult,
|
|
33
|
+
MCPServerInfo,
|
|
34
|
+
MCPTool,
|
|
35
|
+
MCPTransport,
|
|
36
|
+
MCPVulnerability,
|
|
37
|
+
MCPVulnSeverity,
|
|
38
|
+
)
|
|
39
|
+
from ..utils.mcp_cve_db import (
|
|
40
|
+
DANGEROUS_TOOL_KEYWORDS,
|
|
41
|
+
MCP_CVE_DB,
|
|
42
|
+
match_cves,
|
|
43
|
+
scan_for_dangerous_keywords,
|
|
44
|
+
scan_for_secrets,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MCPScanner:
|
|
49
|
+
"""Security scanner for MCP (Model Context Protocol) endpoints."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
target: str,
|
|
54
|
+
transport: str = "http",
|
|
55
|
+
cmd: list[str] | None = None,
|
|
56
|
+
headers: dict[str, str] | None = None,
|
|
57
|
+
timeout: float = 15.0,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Args:
|
|
61
|
+
target: URL for HTTP/SSE transport, or 'stdio://...' for stdio.
|
|
62
|
+
transport: "http", "sse", or "stdio".
|
|
63
|
+
cmd: Command list for stdio transport, e.g. ["python", "server.py"].
|
|
64
|
+
headers: Extra HTTP headers (e.g. Authorization).
|
|
65
|
+
timeout: Per-request timeout in seconds.
|
|
66
|
+
"""
|
|
67
|
+
self.target = target
|
|
68
|
+
self.transport = MCPTransport(transport)
|
|
69
|
+
self.cmd = cmd or []
|
|
70
|
+
self.headers = headers or {}
|
|
71
|
+
self.timeout = timeout
|
|
72
|
+
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
# Public API
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
async def scan(self) -> MCPScanResult:
|
|
78
|
+
"""Connect, enumerate, fingerprint, and assess security posture."""
|
|
79
|
+
start = time.monotonic()
|
|
80
|
+
|
|
81
|
+
if self.transport in (MCPTransport.HTTP, MCPTransport.SSE):
|
|
82
|
+
result = await self._scan_http()
|
|
83
|
+
else:
|
|
84
|
+
result = await asyncio.to_thread(self._scan_stdio)
|
|
85
|
+
|
|
86
|
+
result.scan_duration = time.monotonic() - start
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
# HTTP / SSE scanning
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
async def _scan_http(self) -> MCPScanResult:
|
|
94
|
+
result = MCPScanResult(target=self.target, transport=self.transport)
|
|
95
|
+
|
|
96
|
+
async with httpx.AsyncClient(
|
|
97
|
+
headers={
|
|
98
|
+
"Content-Type": "application/json",
|
|
99
|
+
"Accept": "application/json, text/event-stream",
|
|
100
|
+
"User-Agent": "offsec-ai/2.0.0",
|
|
101
|
+
**self.headers,
|
|
102
|
+
},
|
|
103
|
+
timeout=self.timeout,
|
|
104
|
+
) as client:
|
|
105
|
+
# 1. Initialize handshake
|
|
106
|
+
server_info, init_error = await self._initialize_http(client)
|
|
107
|
+
if init_error:
|
|
108
|
+
result.error = init_error
|
|
109
|
+
# Still probe auth posture so callers know what auth is needed
|
|
110
|
+
if "401" in init_error or "403" in init_error:
|
|
111
|
+
result.auth_posture = MCPAuthPosture(
|
|
112
|
+
requires_auth=True,
|
|
113
|
+
unauthenticated_access=False,
|
|
114
|
+
auth_type="bearer" if "401" in init_error else "unknown",
|
|
115
|
+
notes=init_error,
|
|
116
|
+
)
|
|
117
|
+
return result
|
|
118
|
+
result.server_info = server_info
|
|
119
|
+
|
|
120
|
+
# 2. Auth posture check
|
|
121
|
+
result.auth_posture = await self._check_auth_posture_http(client)
|
|
122
|
+
|
|
123
|
+
# 3. Enumerate capabilities in parallel
|
|
124
|
+
tools, resources, prompts = await asyncio.gather(
|
|
125
|
+
self._list_tools_http(client),
|
|
126
|
+
self._list_resources_http(client),
|
|
127
|
+
self._list_prompts_http(client),
|
|
128
|
+
return_exceptions=False,
|
|
129
|
+
)
|
|
130
|
+
result.tools = tools
|
|
131
|
+
result.resources = resources
|
|
132
|
+
result.prompts = prompts
|
|
133
|
+
|
|
134
|
+
# 4. Security analysis (no network needed)
|
|
135
|
+
result.vulnerabilities = self._analyze_security(result)
|
|
136
|
+
result.cve_matches = self._match_cves(result.server_info)
|
|
137
|
+
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _parse_sse_or_json(resp: httpx.Response) -> dict:
|
|
142
|
+
"""Parse response body — handles both plain JSON and SSE (text/event-stream)."""
|
|
143
|
+
ct = resp.headers.get("content-type", "")
|
|
144
|
+
if "text/event-stream" in ct or resp.text.startswith("event:") or resp.text.startswith("data:"):
|
|
145
|
+
for line in resp.text.splitlines():
|
|
146
|
+
if line.startswith("data:"):
|
|
147
|
+
return json.loads(line[5:].strip())
|
|
148
|
+
raise ValueError("SSE response contained no data: line")
|
|
149
|
+
return resp.json()
|
|
150
|
+
|
|
151
|
+
async def _initialize_http(
|
|
152
|
+
self, client: httpx.AsyncClient
|
|
153
|
+
) -> tuple[MCPServerInfo, str | None]:
|
|
154
|
+
"""Send MCP initialize request and parse server info."""
|
|
155
|
+
payload = {
|
|
156
|
+
"jsonrpc": "2.0",
|
|
157
|
+
"id": 1,
|
|
158
|
+
"method": "initialize",
|
|
159
|
+
"params": {
|
|
160
|
+
"protocolVersion": "2024-11-05",
|
|
161
|
+
"capabilities": {"roots": {"listChanged": False}},
|
|
162
|
+
"clientInfo": {"name": "offsec-ai", "version": "2.0.0"},
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
try:
|
|
166
|
+
resp = await client.post(self.target, json=payload)
|
|
167
|
+
resp.raise_for_status()
|
|
168
|
+
data = self._parse_sse_or_json(resp)
|
|
169
|
+
result_data = data.get("result", {})
|
|
170
|
+
server_info_raw = result_data.get("serverInfo", {})
|
|
171
|
+
capabilities = result_data.get("capabilities", {})
|
|
172
|
+
return MCPServerInfo(
|
|
173
|
+
name=server_info_raw.get("name", ""),
|
|
174
|
+
version=server_info_raw.get("version", ""),
|
|
175
|
+
protocol_version=result_data.get("protocolVersion", ""),
|
|
176
|
+
capabilities=capabilities,
|
|
177
|
+
raw=result_data,
|
|
178
|
+
), None
|
|
179
|
+
except httpx.HTTPStatusError as exc:
|
|
180
|
+
return MCPServerInfo(), f"HTTP {exc.response.status_code}: {exc.response.text[:200]}"
|
|
181
|
+
except Exception as exc:
|
|
182
|
+
return MCPServerInfo(), str(exc)
|
|
183
|
+
|
|
184
|
+
async def _check_auth_posture_http(
|
|
185
|
+
self, client: httpx.AsyncClient
|
|
186
|
+
) -> MCPAuthPosture:
|
|
187
|
+
"""Probe auth requirements by sending an unauthenticated request."""
|
|
188
|
+
posture = MCPAuthPosture()
|
|
189
|
+
|
|
190
|
+
# Try without any auth header
|
|
191
|
+
no_auth_client = httpx.AsyncClient(
|
|
192
|
+
headers={"Content-Type": "application/json", "Accept": "application/json, text/event-stream", "User-Agent": "offsec-ai/2.0.0"},
|
|
193
|
+
timeout=self.timeout,
|
|
194
|
+
)
|
|
195
|
+
async with no_auth_client:
|
|
196
|
+
try:
|
|
197
|
+
payload = {"jsonrpc": "2.0", "id": 99, "method": "initialize",
|
|
198
|
+
"params": {"protocolVersion": "2024-11-05",
|
|
199
|
+
"capabilities": {"roots": {"listChanged": False}},
|
|
200
|
+
"clientInfo": {"name": "probe", "version": "2.0.0"}}}
|
|
201
|
+
resp = await no_auth_client.post(self.target, json=payload)
|
|
202
|
+
if resp.status_code == 200:
|
|
203
|
+
posture.unauthenticated_access = True
|
|
204
|
+
posture.requires_auth = False
|
|
205
|
+
posture.auth_type = "none"
|
|
206
|
+
posture.notes = "Server responded to unauthenticated initialize request."
|
|
207
|
+
elif resp.status_code in (401, 403):
|
|
208
|
+
posture.requires_auth = True
|
|
209
|
+
auth_header = resp.headers.get("WWW-Authenticate", "")
|
|
210
|
+
if "bearer" in auth_header.lower():
|
|
211
|
+
posture.auth_type = "bearer"
|
|
212
|
+
elif "basic" in auth_header.lower():
|
|
213
|
+
posture.auth_type = "basic"
|
|
214
|
+
else:
|
|
215
|
+
posture.auth_type = "unknown"
|
|
216
|
+
except Exception:
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
return posture
|
|
220
|
+
|
|
221
|
+
async def _list_tools_http(self, client: httpx.AsyncClient) -> list[MCPTool]:
|
|
222
|
+
payload = {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}
|
|
223
|
+
try:
|
|
224
|
+
resp = await client.post(self.target, json=payload)
|
|
225
|
+
resp.raise_for_status()
|
|
226
|
+
data = self._parse_sse_or_json(resp)
|
|
227
|
+
tools_raw = data.get("result", {}).get("tools", [])
|
|
228
|
+
return [self._parse_tool(t) for t in tools_raw]
|
|
229
|
+
except Exception:
|
|
230
|
+
return []
|
|
231
|
+
|
|
232
|
+
async def _list_resources_http(self, client: httpx.AsyncClient) -> list[MCPResource]:
|
|
233
|
+
payload = {"jsonrpc": "2.0", "id": 3, "method": "resources/list", "params": {}}
|
|
234
|
+
try:
|
|
235
|
+
resp = await client.post(self.target, json=payload)
|
|
236
|
+
resp.raise_for_status()
|
|
237
|
+
data = self._parse_sse_or_json(resp)
|
|
238
|
+
resources_raw = data.get("result", {}).get("resources", [])
|
|
239
|
+
return [
|
|
240
|
+
MCPResource(
|
|
241
|
+
uri=r.get("uri", ""),
|
|
242
|
+
name=r.get("name", ""),
|
|
243
|
+
description=r.get("description", ""),
|
|
244
|
+
mime_type=r.get("mimeType", ""),
|
|
245
|
+
)
|
|
246
|
+
for r in resources_raw
|
|
247
|
+
]
|
|
248
|
+
except Exception:
|
|
249
|
+
return []
|
|
250
|
+
|
|
251
|
+
async def _list_prompts_http(self, client: httpx.AsyncClient) -> list[MCPPrompt]:
|
|
252
|
+
payload = {"jsonrpc": "2.0", "id": 4, "method": "prompts/list", "params": {}}
|
|
253
|
+
try:
|
|
254
|
+
resp = await client.post(self.target, json=payload)
|
|
255
|
+
resp.raise_for_status()
|
|
256
|
+
data = self._parse_sse_or_json(resp)
|
|
257
|
+
prompts_raw = data.get("result", {}).get("prompts", [])
|
|
258
|
+
return [
|
|
259
|
+
MCPPrompt(
|
|
260
|
+
name=p.get("name", ""),
|
|
261
|
+
description=p.get("description", ""),
|
|
262
|
+
arguments=p.get("arguments", []),
|
|
263
|
+
)
|
|
264
|
+
for p in prompts_raw
|
|
265
|
+
]
|
|
266
|
+
except Exception:
|
|
267
|
+
return []
|
|
268
|
+
|
|
269
|
+
# ------------------------------------------------------------------
|
|
270
|
+
# stdio scanning (blocking, runs in thread pool)
|
|
271
|
+
# ------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
def _scan_stdio(self) -> MCPScanResult:
|
|
274
|
+
"""Launch MCP server as subprocess and communicate via stdin/stdout."""
|
|
275
|
+
result = MCPScanResult(target=self.target, transport=MCPTransport.STDIO)
|
|
276
|
+
|
|
277
|
+
if not self.cmd:
|
|
278
|
+
result.error = "stdio transport requires --cmd to be specified."
|
|
279
|
+
return result
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
proc = subprocess.Popen(
|
|
283
|
+
self.cmd,
|
|
284
|
+
stdin=subprocess.PIPE,
|
|
285
|
+
stdout=subprocess.PIPE,
|
|
286
|
+
stderr=subprocess.PIPE,
|
|
287
|
+
text=True,
|
|
288
|
+
)
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
result.error = f"Failed to start MCP server: {exc}"
|
|
291
|
+
return result
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
result.server_info = self._stdio_initialize(proc)
|
|
295
|
+
result.tools = self._stdio_list_tools(proc)
|
|
296
|
+
result.resources = self._stdio_list_resources(proc)
|
|
297
|
+
result.prompts = self._stdio_list_prompts(proc)
|
|
298
|
+
result.auth_posture = MCPAuthPosture(
|
|
299
|
+
requires_auth=False,
|
|
300
|
+
auth_type="none",
|
|
301
|
+
unauthenticated_access=True,
|
|
302
|
+
notes="stdio transport — no network auth layer.",
|
|
303
|
+
)
|
|
304
|
+
except Exception as exc:
|
|
305
|
+
result.error = str(exc)
|
|
306
|
+
finally:
|
|
307
|
+
proc.stdin.close() # type: ignore[union-attr]
|
|
308
|
+
proc.terminate()
|
|
309
|
+
proc.wait(timeout=5)
|
|
310
|
+
|
|
311
|
+
result.vulnerabilities = self._analyze_security(result)
|
|
312
|
+
result.cve_matches = self._match_cves(result.server_info)
|
|
313
|
+
return result
|
|
314
|
+
|
|
315
|
+
def _stdio_rpc(self, proc: subprocess.Popen, request: dict) -> dict:
|
|
316
|
+
"""Send a JSON-RPC request over stdio and read the response."""
|
|
317
|
+
line = json.dumps(request) + "\n"
|
|
318
|
+
proc.stdin.write(line) # type: ignore[union-attr]
|
|
319
|
+
proc.stdin.flush() # type: ignore[union-attr]
|
|
320
|
+
response_line = proc.stdout.readline() # type: ignore[union-attr]
|
|
321
|
+
return json.loads(response_line)
|
|
322
|
+
|
|
323
|
+
def _stdio_initialize(self, proc: subprocess.Popen) -> MCPServerInfo:
|
|
324
|
+
resp = self._stdio_rpc(proc, {
|
|
325
|
+
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
326
|
+
"params": {"protocolVersion": "2024-11-05", "capabilities": {},
|
|
327
|
+
"clientInfo": {"name": "offsec-ai", "version": "2.0.0"}},
|
|
328
|
+
})
|
|
329
|
+
result_data = resp.get("result", {})
|
|
330
|
+
server_info_raw = result_data.get("serverInfo", {})
|
|
331
|
+
return MCPServerInfo(
|
|
332
|
+
name=server_info_raw.get("name", ""),
|
|
333
|
+
version=server_info_raw.get("version", ""),
|
|
334
|
+
protocol_version=result_data.get("protocolVersion", ""),
|
|
335
|
+
capabilities=result_data.get("capabilities", {}),
|
|
336
|
+
raw=result_data,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def _stdio_list_tools(self, proc: subprocess.Popen) -> list[MCPTool]:
|
|
340
|
+
resp = self._stdio_rpc(proc, {"jsonrpc": "2.0", "id": 2,
|
|
341
|
+
"method": "tools/list", "params": {}})
|
|
342
|
+
return [self._parse_tool(t) for t in resp.get("result", {}).get("tools", [])]
|
|
343
|
+
|
|
344
|
+
def _stdio_list_resources(self, proc: subprocess.Popen) -> list[MCPResource]:
|
|
345
|
+
resp = self._stdio_rpc(proc, {"jsonrpc": "2.0", "id": 3,
|
|
346
|
+
"method": "resources/list", "params": {}})
|
|
347
|
+
return [
|
|
348
|
+
MCPResource(uri=r.get("uri", ""), name=r.get("name", ""),
|
|
349
|
+
description=r.get("description", ""),
|
|
350
|
+
mime_type=r.get("mimeType", ""))
|
|
351
|
+
for r in resp.get("result", {}).get("resources", [])
|
|
352
|
+
]
|
|
353
|
+
|
|
354
|
+
def _stdio_list_prompts(self, proc: subprocess.Popen) -> list[MCPPrompt]:
|
|
355
|
+
resp = self._stdio_rpc(proc, {"jsonrpc": "2.0", "id": 4,
|
|
356
|
+
"method": "prompts/list", "params": {}})
|
|
357
|
+
return [
|
|
358
|
+
MCPPrompt(name=p.get("name", ""), description=p.get("description", ""),
|
|
359
|
+
arguments=p.get("arguments", []))
|
|
360
|
+
for p in resp.get("result", {}).get("prompts", [])
|
|
361
|
+
]
|
|
362
|
+
|
|
363
|
+
# ------------------------------------------------------------------
|
|
364
|
+
# Security analysis (no network)
|
|
365
|
+
# ------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
def _parse_tool(self, raw: dict) -> MCPTool:
|
|
368
|
+
desc = raw.get("description", "")
|
|
369
|
+
dangerous_kw = scan_for_dangerous_keywords(desc)
|
|
370
|
+
return MCPTool(
|
|
371
|
+
name=raw.get("name", ""),
|
|
372
|
+
description=desc,
|
|
373
|
+
input_schema=raw.get("inputSchema", {}),
|
|
374
|
+
has_dangerous_keywords=bool(dangerous_kw),
|
|
375
|
+
dangerous_keywords_found=dangerous_kw,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
def _analyze_security(self, result: MCPScanResult) -> list[MCPVulnerability]:
|
|
379
|
+
vulns: list[MCPVulnerability] = []
|
|
380
|
+
|
|
381
|
+
# Unauthenticated access
|
|
382
|
+
if result.auth_posture.unauthenticated_access:
|
|
383
|
+
vulns.append(MCPVulnerability(
|
|
384
|
+
vuln_id="OFFSEC-MCP-AUTH-001",
|
|
385
|
+
severity=MCPVulnSeverity.HIGH,
|
|
386
|
+
title="Unauthenticated MCP Endpoint",
|
|
387
|
+
description=(
|
|
388
|
+
"The MCP server accepted an unauthenticated initialize request. "
|
|
389
|
+
"Any client can enumerate tools, resources, and prompts without credentials."
|
|
390
|
+
),
|
|
391
|
+
remediation="Require OAuth 2.0 or API key authentication on the MCP endpoint.",
|
|
392
|
+
references=["https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/authorization/"],
|
|
393
|
+
affected_component="initialization",
|
|
394
|
+
))
|
|
395
|
+
|
|
396
|
+
# Tool description analysis
|
|
397
|
+
for tool in result.tools:
|
|
398
|
+
if tool.has_dangerous_keywords:
|
|
399
|
+
vulns.append(MCPVulnerability(
|
|
400
|
+
vuln_id="OFFSEC-MCP-TI-001",
|
|
401
|
+
severity=MCPVulnSeverity.HIGH,
|
|
402
|
+
title=f"Dangerous Keywords in Tool Description: '{tool.name}'",
|
|
403
|
+
description=(
|
|
404
|
+
f"Tool '{tool.name}' description contains keywords associated "
|
|
405
|
+
f"with tool-poisoning or injection attacks: "
|
|
406
|
+
f"{', '.join(tool.dangerous_keywords_found[:5])}."
|
|
407
|
+
),
|
|
408
|
+
evidence=tool.description[:300],
|
|
409
|
+
remediation=(
|
|
410
|
+
"Audit and sanitize tool descriptions. Remove system override instructions "
|
|
411
|
+
"and shell/eval references. Verify tool implementations."
|
|
412
|
+
),
|
|
413
|
+
affected_component=f"tool:{tool.name}",
|
|
414
|
+
))
|
|
415
|
+
|
|
416
|
+
# Secrets in tool description
|
|
417
|
+
secrets = scan_for_secrets(tool.description)
|
|
418
|
+
if secrets:
|
|
419
|
+
vulns.append(MCPVulnerability(
|
|
420
|
+
vuln_id="OFFSEC-MCP-SEC-001",
|
|
421
|
+
severity=MCPVulnSeverity.CRITICAL,
|
|
422
|
+
title=f"Secret/Credential Pattern in Tool Description: '{tool.name}'",
|
|
423
|
+
description=(
|
|
424
|
+
f"Tool '{tool.name}' description may contain credentials or secrets. "
|
|
425
|
+
f"Patterns matched: {', '.join(secrets[:5])}."
|
|
426
|
+
),
|
|
427
|
+
evidence=tool.description[:300],
|
|
428
|
+
remediation="Remove all secrets from tool descriptions. Use environment variables.",
|
|
429
|
+
affected_component=f"tool:{tool.name}",
|
|
430
|
+
))
|
|
431
|
+
|
|
432
|
+
# Resource URI path traversal check
|
|
433
|
+
for resource in result.resources:
|
|
434
|
+
if ".." in resource.uri or resource.uri.startswith("/"):
|
|
435
|
+
vulns.append(MCPVulnerability(
|
|
436
|
+
vuln_id="OFFSEC-MCP-PT-001",
|
|
437
|
+
severity=MCPVulnSeverity.HIGH,
|
|
438
|
+
title=f"Potential Path Traversal in Resource URI: '{resource.uri}'",
|
|
439
|
+
description=(
|
|
440
|
+
f"Resource URI '{resource.uri}' contains path traversal indicators "
|
|
441
|
+
f"('..') or absolute paths that may expose sensitive files."
|
|
442
|
+
),
|
|
443
|
+
evidence=resource.uri,
|
|
444
|
+
remediation=(
|
|
445
|
+
"Validate and canonicalize all resource URIs. "
|
|
446
|
+
"Enforce an allowed-path allowlist."
|
|
447
|
+
),
|
|
448
|
+
affected_component=f"resource:{resource.uri}",
|
|
449
|
+
))
|
|
450
|
+
|
|
451
|
+
# Prompt description secrets check
|
|
452
|
+
for prompt in result.prompts:
|
|
453
|
+
secrets = scan_for_secrets(prompt.description)
|
|
454
|
+
if secrets:
|
|
455
|
+
vulns.append(MCPVulnerability(
|
|
456
|
+
vuln_id="OFFSEC-MCP-SEC-002",
|
|
457
|
+
severity=MCPVulnSeverity.HIGH,
|
|
458
|
+
title=f"Secret Pattern in Prompt Description: '{prompt.name}'",
|
|
459
|
+
description=(
|
|
460
|
+
f"Prompt '{prompt.name}' description may contain sensitive data. "
|
|
461
|
+
f"Patterns matched: {', '.join(secrets[:5])}."
|
|
462
|
+
),
|
|
463
|
+
evidence=prompt.description[:300],
|
|
464
|
+
remediation="Audit all prompt descriptions for embedded credentials.",
|
|
465
|
+
affected_component=f"prompt:{prompt.name}",
|
|
466
|
+
))
|
|
467
|
+
|
|
468
|
+
# Overly broad tool scope heuristic
|
|
469
|
+
shell_like = [
|
|
470
|
+
t for t in result.tools
|
|
471
|
+
if any(k in t.name.lower() for k in ["shell", "exec", "run", "bash", "cmd", "terminal"])
|
|
472
|
+
]
|
|
473
|
+
if shell_like:
|
|
474
|
+
for t in shell_like:
|
|
475
|
+
vulns.append(MCPVulnerability(
|
|
476
|
+
vuln_id="OFFSEC-MCP-SCOPE-001",
|
|
477
|
+
severity=MCPVulnSeverity.CRITICAL,
|
|
478
|
+
title=f"Shell/Execution Tool Exposed: '{t.name}'",
|
|
479
|
+
description=(
|
|
480
|
+
f"Tool '{t.name}' appears to provide shell or command execution capabilities. "
|
|
481
|
+
f"If LLM-accessible, this creates a high-risk command injection vector."
|
|
482
|
+
),
|
|
483
|
+
remediation=(
|
|
484
|
+
"Remove shell-execution tools unless strictly necessary. "
|
|
485
|
+
"Implement strict argument allow-lists and subprocess hardening."
|
|
486
|
+
),
|
|
487
|
+
affected_component=f"tool:{t.name}",
|
|
488
|
+
))
|
|
489
|
+
|
|
490
|
+
return vulns
|
|
491
|
+
|
|
492
|
+
def _match_cves(self, server_info: MCPServerInfo) -> list[MCPVulnerability]:
|
|
493
|
+
"""Match server fingerprint against CVE database."""
|
|
494
|
+
entries = match_cves(server_info.name, server_info.version)
|
|
495
|
+
return [
|
|
496
|
+
MCPVulnerability(
|
|
497
|
+
vuln_id=entry.vuln_id,
|
|
498
|
+
cve_id=entry.cve_id,
|
|
499
|
+
severity=MCPVulnSeverity(entry.severity),
|
|
500
|
+
title=entry.title,
|
|
501
|
+
description=entry.description,
|
|
502
|
+
remediation=entry.remediation,
|
|
503
|
+
references=entry.references,
|
|
504
|
+
)
|
|
505
|
+
for entry in entries
|
|
506
|
+
]
|