agent-audit 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.
- agent_audit/__init__.py +3 -0
- agent_audit/__main__.py +13 -0
- agent_audit/cli/__init__.py +1 -0
- agent_audit/cli/commands/__init__.py +1 -0
- agent_audit/cli/commands/init.py +44 -0
- agent_audit/cli/commands/inspect.py +236 -0
- agent_audit/cli/commands/scan.py +329 -0
- agent_audit/cli/formatters/__init__.py +1 -0
- agent_audit/cli/formatters/json.py +138 -0
- agent_audit/cli/formatters/sarif.py +155 -0
- agent_audit/cli/formatters/terminal.py +221 -0
- agent_audit/cli/main.py +34 -0
- agent_audit/config/__init__.py +1 -0
- agent_audit/config/ignore.py +477 -0
- agent_audit/core_utils/__init__.py +1 -0
- agent_audit/models/__init__.py +18 -0
- agent_audit/models/finding.py +159 -0
- agent_audit/models/risk.py +77 -0
- agent_audit/models/tool.py +182 -0
- agent_audit/rules/__init__.py +6 -0
- agent_audit/rules/engine.py +503 -0
- agent_audit/rules/loader.py +160 -0
- agent_audit/scanners/__init__.py +5 -0
- agent_audit/scanners/base.py +32 -0
- agent_audit/scanners/config_scanner.py +390 -0
- agent_audit/scanners/mcp_config_scanner.py +321 -0
- agent_audit/scanners/mcp_inspector.py +421 -0
- agent_audit/scanners/python_scanner.py +544 -0
- agent_audit/scanners/secret_scanner.py +521 -0
- agent_audit/utils/__init__.py +21 -0
- agent_audit/utils/compat.py +98 -0
- agent_audit/utils/mcp_client.py +343 -0
- agent_audit/version.py +3 -0
- agent_audit-0.1.0.dist-info/METADATA +219 -0
- agent_audit-0.1.0.dist-info/RECORD +37 -0
- agent_audit-0.1.0.dist-info/WHEEL +4 -0
- agent_audit-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Inspector - Runtime MCP Server probe ("Agent Nmap").
|
|
3
|
+
|
|
4
|
+
Connects to MCP servers to discover and analyze their capabilities
|
|
5
|
+
WITHOUT executing any tools.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from typing import Dict, Any, List, Optional, Set
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from contextlib import asynccontextmanager
|
|
15
|
+
|
|
16
|
+
from agent_audit.models.tool import ToolDefinition, PermissionType, ToolParameter
|
|
17
|
+
from agent_audit.utils.mcp_client import (
|
|
18
|
+
BaseMCPTransport, TransportType, create_client, infer_transport_type
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Python 3.9 compatibility for asyncio.timeout
|
|
23
|
+
if sys.version_info >= (3, 11):
|
|
24
|
+
from asyncio import timeout as async_timeout
|
|
25
|
+
else:
|
|
26
|
+
@asynccontextmanager
|
|
27
|
+
async def async_timeout(delay: float):
|
|
28
|
+
"""Compatibility wrapper for asyncio.timeout on Python < 3.11."""
|
|
29
|
+
task = asyncio.current_task()
|
|
30
|
+
loop = asyncio.get_event_loop()
|
|
31
|
+
deadline = loop.time() + delay
|
|
32
|
+
handle = loop.call_at(deadline, task.cancel)
|
|
33
|
+
try:
|
|
34
|
+
yield
|
|
35
|
+
except asyncio.CancelledError:
|
|
36
|
+
raise asyncio.TimeoutError()
|
|
37
|
+
finally:
|
|
38
|
+
handle.cancel()
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class MCPInspectionResult:
|
|
45
|
+
"""Result of MCP server inspection."""
|
|
46
|
+
server_name: str
|
|
47
|
+
server_version: Optional[str] = None
|
|
48
|
+
transport: TransportType = TransportType.SSE
|
|
49
|
+
|
|
50
|
+
# Tools
|
|
51
|
+
tools: List[ToolDefinition] = field(default_factory=list)
|
|
52
|
+
tool_count: int = 0
|
|
53
|
+
|
|
54
|
+
# Resources
|
|
55
|
+
resources: List[Dict[str, Any]] = field(default_factory=list)
|
|
56
|
+
resource_count: int = 0
|
|
57
|
+
|
|
58
|
+
# Prompts
|
|
59
|
+
prompts: List[Dict[str, Any]] = field(default_factory=list)
|
|
60
|
+
prompt_count: int = 0
|
|
61
|
+
|
|
62
|
+
# Security analysis
|
|
63
|
+
risk_score: float = 0.0
|
|
64
|
+
findings: List[Dict[str, Any]] = field(default_factory=list)
|
|
65
|
+
capabilities_declared: List[str] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
# Connection info
|
|
68
|
+
connected: bool = False
|
|
69
|
+
connection_error: Optional[str] = None
|
|
70
|
+
response_time_ms: float = 0.0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class MCPInspector:
|
|
74
|
+
"""
|
|
75
|
+
Safe MCP Server inspector.
|
|
76
|
+
|
|
77
|
+
Security principles:
|
|
78
|
+
1. Only sends: initialize, tools/list, resources/list, prompts/list
|
|
79
|
+
2. NEVER calls tools/call (no tool execution)
|
|
80
|
+
3. Timeout protection against malicious servers
|
|
81
|
+
4. Does not trust server responses for code execution
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
# High-risk tool name keywords
|
|
85
|
+
HIGH_RISK_KEYWORDS = {
|
|
86
|
+
'exec', 'shell', 'command', 'run', 'eval', 'system',
|
|
87
|
+
'sudo', 'admin', 'root', 'delete', 'remove', 'drop',
|
|
88
|
+
'truncate', 'format', 'destroy', 'kill', 'rm',
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# Keywords indicating specific permissions
|
|
92
|
+
PERMISSION_KEYWORDS = {
|
|
93
|
+
PermissionType.SHELL_EXEC: ['exec', 'shell', 'command', 'bash', 'terminal', 'run'],
|
|
94
|
+
PermissionType.FILE_READ: ['read', 'file', 'load', 'open', 'cat', 'get_file'],
|
|
95
|
+
PermissionType.FILE_WRITE: ['write', 'save', 'create', 'modify', 'edit', 'put_file'],
|
|
96
|
+
PermissionType.FILE_DELETE: ['delete', 'remove', 'unlink', 'rm', 'rmdir'],
|
|
97
|
+
PermissionType.NETWORK_OUTBOUND: ['http', 'request', 'fetch', 'api', 'url', 'web', 'download', 'upload'],
|
|
98
|
+
PermissionType.DATABASE_READ: ['query', 'sql', 'database', 'db', 'select'],
|
|
99
|
+
PermissionType.DATABASE_WRITE: ['insert', 'update', 'drop', 'alter'],
|
|
100
|
+
PermissionType.SECRET_ACCESS: ['secret', 'credential', 'password', 'key', 'token', 'auth'],
|
|
101
|
+
PermissionType.BROWSER_CONTROL: ['browser', 'playwright', 'puppeteer', 'selenium', 'chrome'],
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Sensitive resource patterns (cross-platform)
|
|
105
|
+
SENSITIVE_RESOURCE_PATTERNS = [
|
|
106
|
+
# Unix sensitive paths
|
|
107
|
+
'/etc/', '.ssh/', '.aws/', '.env',
|
|
108
|
+
'credentials', 'secret', 'password', 'token',
|
|
109
|
+
'private_key', '.git/config', '.npmrc',
|
|
110
|
+
# Windows equivalents
|
|
111
|
+
'system32/config', 'AppData/Roaming', 'AppData/Local',
|
|
112
|
+
'%USERPROFILE%', '%APPDATA%', 'id_rsa', 'id_ed25519',
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
def __init__(self, timeout: int = 30):
|
|
116
|
+
"""
|
|
117
|
+
Initialize the inspector.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
timeout: Connection and request timeout in seconds
|
|
121
|
+
"""
|
|
122
|
+
self.timeout = timeout
|
|
123
|
+
|
|
124
|
+
async def inspect(
|
|
125
|
+
self,
|
|
126
|
+
target: str,
|
|
127
|
+
transport: Optional[TransportType] = None
|
|
128
|
+
) -> MCPInspectionResult:
|
|
129
|
+
"""
|
|
130
|
+
Inspect an MCP server.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
target: Server target specification
|
|
134
|
+
- "https://example.com/sse" -> SSE transport
|
|
135
|
+
- "python server.py" -> STDIO transport
|
|
136
|
+
transport: Explicit transport type (auto-detected if None)
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Inspection result with tools, resources, prompts, and risk analysis
|
|
140
|
+
"""
|
|
141
|
+
start_time = time.perf_counter()
|
|
142
|
+
|
|
143
|
+
# Infer transport type if not specified
|
|
144
|
+
if transport is None:
|
|
145
|
+
transport = infer_transport_type(target)
|
|
146
|
+
|
|
147
|
+
result = MCPInspectionResult(
|
|
148
|
+
server_name="unknown",
|
|
149
|
+
transport=transport
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
client: Optional[BaseMCPTransport] = None
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
async with async_timeout(self.timeout):
|
|
156
|
+
# Connect to server
|
|
157
|
+
client = await create_client(target, transport)
|
|
158
|
+
|
|
159
|
+
# 1. Initialize connection
|
|
160
|
+
init_response = await client.send("initialize", {
|
|
161
|
+
"protocolVersion": "2024-11-05",
|
|
162
|
+
"capabilities": {},
|
|
163
|
+
"clientInfo": {
|
|
164
|
+
"name": "agent-audit-inspector",
|
|
165
|
+
"version": "0.1.0"
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
result.server_name = init_response.get("serverInfo", {}).get("name", "unknown")
|
|
170
|
+
result.server_version = init_response.get("serverInfo", {}).get("version")
|
|
171
|
+
result.capabilities_declared = list(init_response.get("capabilities", {}).keys())
|
|
172
|
+
|
|
173
|
+
# Send initialized notification
|
|
174
|
+
await client.notify("notifications/initialized", {})
|
|
175
|
+
|
|
176
|
+
# 2. List tools
|
|
177
|
+
tools_response = await client.send("tools/list", {})
|
|
178
|
+
raw_tools = tools_response.get("tools", [])
|
|
179
|
+
result.tools = [self._analyze_tool(t) for t in raw_tools]
|
|
180
|
+
result.tool_count = len(result.tools)
|
|
181
|
+
|
|
182
|
+
# 3. List resources (may not be supported)
|
|
183
|
+
try:
|
|
184
|
+
resources_response = await client.send("resources/list", {})
|
|
185
|
+
result.resources = resources_response.get("resources", [])
|
|
186
|
+
result.resource_count = len(result.resources)
|
|
187
|
+
except Exception:
|
|
188
|
+
pass # Server may not support resources
|
|
189
|
+
|
|
190
|
+
# 4. List prompts (may not be supported)
|
|
191
|
+
try:
|
|
192
|
+
prompts_response = await client.send("prompts/list", {})
|
|
193
|
+
result.prompts = prompts_response.get("prompts", [])
|
|
194
|
+
result.prompt_count = len(result.prompts)
|
|
195
|
+
except Exception:
|
|
196
|
+
pass # Server may not support prompts
|
|
197
|
+
|
|
198
|
+
result.connected = True
|
|
199
|
+
|
|
200
|
+
except asyncio.TimeoutError:
|
|
201
|
+
result.connection_error = f"Connection timed out after {self.timeout}s"
|
|
202
|
+
except Exception as e:
|
|
203
|
+
result.connection_error = str(e)
|
|
204
|
+
finally:
|
|
205
|
+
if client:
|
|
206
|
+
try:
|
|
207
|
+
await client.close()
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
result.response_time_ms = (time.perf_counter() - start_time) * 1000
|
|
212
|
+
|
|
213
|
+
# Perform security analysis
|
|
214
|
+
if result.connected:
|
|
215
|
+
result.risk_score = self._calculate_risk_score(result)
|
|
216
|
+
result.findings = self._generate_findings(result)
|
|
217
|
+
|
|
218
|
+
return result
|
|
219
|
+
|
|
220
|
+
def _analyze_tool(self, raw_tool: Dict[str, Any]) -> ToolDefinition:
|
|
221
|
+
"""Analyze a tool definition from the server."""
|
|
222
|
+
name = raw_tool.get("name", "unknown")
|
|
223
|
+
description = raw_tool.get("description", "")
|
|
224
|
+
input_schema = raw_tool.get("inputSchema", {})
|
|
225
|
+
|
|
226
|
+
# Infer permissions from name and description
|
|
227
|
+
permissions = self._infer_permissions(name, description)
|
|
228
|
+
|
|
229
|
+
# Analyze input schema for validation
|
|
230
|
+
schema_analysis = self._analyze_input_schema(input_schema)
|
|
231
|
+
|
|
232
|
+
# Extract parameters
|
|
233
|
+
parameters = self._extract_parameters(input_schema)
|
|
234
|
+
|
|
235
|
+
tool = ToolDefinition(
|
|
236
|
+
name=name,
|
|
237
|
+
description=description,
|
|
238
|
+
source_file="mcp_server",
|
|
239
|
+
source_line=0,
|
|
240
|
+
permissions=permissions,
|
|
241
|
+
parameters=parameters,
|
|
242
|
+
has_input_validation=schema_analysis.get("has_validation", False),
|
|
243
|
+
mcp_server="remote",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
tool.update_capability_flags()
|
|
247
|
+
tool.risk_level = tool.infer_risk_level()
|
|
248
|
+
|
|
249
|
+
return tool
|
|
250
|
+
|
|
251
|
+
def _infer_permissions(self, name: str, description: str) -> Set[PermissionType]:
|
|
252
|
+
"""Infer tool permissions from name and description."""
|
|
253
|
+
permissions: Set[PermissionType] = set()
|
|
254
|
+
combined = (name + " " + description).lower()
|
|
255
|
+
|
|
256
|
+
for permission, keywords in self.PERMISSION_KEYWORDS.items():
|
|
257
|
+
if any(kw in combined for kw in keywords):
|
|
258
|
+
permissions.add(permission)
|
|
259
|
+
|
|
260
|
+
return permissions
|
|
261
|
+
|
|
262
|
+
def _analyze_input_schema(self, schema: Dict[str, Any]) -> Dict[str, Any]:
|
|
263
|
+
"""Analyze input schema for security properties."""
|
|
264
|
+
result = {
|
|
265
|
+
"has_validation": False,
|
|
266
|
+
"unconstrained_strings": [],
|
|
267
|
+
"has_enum": False,
|
|
268
|
+
"has_pattern": False,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
properties = schema.get("properties", {})
|
|
272
|
+
|
|
273
|
+
for param_name, param_def in properties.items():
|
|
274
|
+
param_type = param_def.get("type", "string")
|
|
275
|
+
|
|
276
|
+
if param_type == "string":
|
|
277
|
+
if "enum" in param_def:
|
|
278
|
+
result["has_enum"] = True
|
|
279
|
+
result["has_validation"] = True
|
|
280
|
+
elif "pattern" in param_def:
|
|
281
|
+
result["has_pattern"] = True
|
|
282
|
+
result["has_validation"] = True
|
|
283
|
+
elif "maxLength" in param_def or "minLength" in param_def:
|
|
284
|
+
result["has_validation"] = True
|
|
285
|
+
else:
|
|
286
|
+
result["unconstrained_strings"].append(param_name)
|
|
287
|
+
|
|
288
|
+
elif param_type in ("integer", "number"):
|
|
289
|
+
if "minimum" in param_def or "maximum" in param_def:
|
|
290
|
+
result["has_validation"] = True
|
|
291
|
+
|
|
292
|
+
return result
|
|
293
|
+
|
|
294
|
+
def _extract_parameters(self, schema: Dict[str, Any]) -> List[ToolParameter]:
|
|
295
|
+
"""Extract parameter definitions from schema."""
|
|
296
|
+
parameters = []
|
|
297
|
+
properties = schema.get("properties", {})
|
|
298
|
+
required = set(schema.get("required", []))
|
|
299
|
+
|
|
300
|
+
for param_name, param_def in properties.items():
|
|
301
|
+
param_type = param_def.get("type", "string")
|
|
302
|
+
is_constrained = (
|
|
303
|
+
"enum" in param_def or
|
|
304
|
+
"pattern" in param_def or
|
|
305
|
+
"maxLength" in param_def
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
param = ToolParameter(
|
|
309
|
+
name=param_name,
|
|
310
|
+
type=param_type,
|
|
311
|
+
required=param_name in required,
|
|
312
|
+
description=param_def.get("description"),
|
|
313
|
+
allows_arbitrary_input=not is_constrained and param_type == "string",
|
|
314
|
+
sanitization_present=is_constrained,
|
|
315
|
+
)
|
|
316
|
+
parameters.append(param)
|
|
317
|
+
|
|
318
|
+
return parameters
|
|
319
|
+
|
|
320
|
+
def _calculate_risk_score(self, result: MCPInspectionResult) -> float:
|
|
321
|
+
"""Calculate overall risk score for the server."""
|
|
322
|
+
if not result.connected:
|
|
323
|
+
return 0.0
|
|
324
|
+
|
|
325
|
+
score = 0.0
|
|
326
|
+
|
|
327
|
+
# Tool risk contributions
|
|
328
|
+
for tool in result.tools:
|
|
329
|
+
tool_risk = tool.calculate_risk_score()
|
|
330
|
+
score += tool_risk * 0.15 # Each tool contributes to overall risk
|
|
331
|
+
|
|
332
|
+
# High-risk tool name bonus
|
|
333
|
+
for tool in result.tools:
|
|
334
|
+
name_lower = tool.name.lower()
|
|
335
|
+
if any(kw in name_lower for kw in self.HIGH_RISK_KEYWORDS):
|
|
336
|
+
score += 0.8
|
|
337
|
+
|
|
338
|
+
# Unconstrained string parameters
|
|
339
|
+
for tool in result.tools:
|
|
340
|
+
unconstrained = sum(
|
|
341
|
+
1 for p in tool.parameters
|
|
342
|
+
if p.allows_arbitrary_input
|
|
343
|
+
)
|
|
344
|
+
score += unconstrained * 0.2
|
|
345
|
+
|
|
346
|
+
# Sensitive resource exposure
|
|
347
|
+
for resource in result.resources:
|
|
348
|
+
uri = resource.get("uri", "").lower()
|
|
349
|
+
name = resource.get("name", "").lower()
|
|
350
|
+
if any(pattern in uri or pattern in name
|
|
351
|
+
for pattern in self.SENSITIVE_RESOURCE_PATTERNS):
|
|
352
|
+
score += 0.5
|
|
353
|
+
|
|
354
|
+
# Excessive tool count
|
|
355
|
+
if result.tool_count > 20:
|
|
356
|
+
score += 1.0
|
|
357
|
+
elif result.tool_count > 10:
|
|
358
|
+
score += 0.5
|
|
359
|
+
|
|
360
|
+
return min(10.0, score)
|
|
361
|
+
|
|
362
|
+
def _generate_findings(self, result: MCPInspectionResult) -> List[Dict[str, Any]]:
|
|
363
|
+
"""Generate security findings from inspection."""
|
|
364
|
+
findings = []
|
|
365
|
+
|
|
366
|
+
# Check for high-risk tools
|
|
367
|
+
for tool in result.tools:
|
|
368
|
+
name_lower = tool.name.lower()
|
|
369
|
+
if any(kw in name_lower for kw in self.HIGH_RISK_KEYWORDS):
|
|
370
|
+
findings.append({
|
|
371
|
+
"type": "high_risk_tool",
|
|
372
|
+
"tool": tool.name,
|
|
373
|
+
"description": "Tool name contains high-risk keyword",
|
|
374
|
+
"severity": "high"
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
# Check for unconstrained input
|
|
378
|
+
unconstrained = [
|
|
379
|
+
p.name for p in tool.parameters
|
|
380
|
+
if p.allows_arbitrary_input
|
|
381
|
+
]
|
|
382
|
+
if unconstrained and PermissionType.SHELL_EXEC in tool.permissions:
|
|
383
|
+
findings.append({
|
|
384
|
+
"type": "unconstrained_dangerous_input",
|
|
385
|
+
"tool": tool.name,
|
|
386
|
+
"parameters": unconstrained,
|
|
387
|
+
"description": "Shell execution tool accepts unconstrained string input",
|
|
388
|
+
"severity": "critical"
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
# Check for sensitive resources
|
|
392
|
+
for resource in result.resources:
|
|
393
|
+
uri = resource.get("uri", "").lower()
|
|
394
|
+
for pattern in self.SENSITIVE_RESOURCE_PATTERNS:
|
|
395
|
+
if pattern in uri:
|
|
396
|
+
findings.append({
|
|
397
|
+
"type": "sensitive_resource",
|
|
398
|
+
"resource": resource.get("uri"),
|
|
399
|
+
"pattern": pattern,
|
|
400
|
+
"description": "Resource exposes potentially sensitive path",
|
|
401
|
+
"severity": "medium"
|
|
402
|
+
})
|
|
403
|
+
break
|
|
404
|
+
|
|
405
|
+
# Check for excessive permissions
|
|
406
|
+
all_permissions: Set[PermissionType] = set()
|
|
407
|
+
for tool in result.tools:
|
|
408
|
+
all_permissions.update(tool.permissions)
|
|
409
|
+
|
|
410
|
+
dangerous_combo = {
|
|
411
|
+
PermissionType.SECRET_ACCESS,
|
|
412
|
+
PermissionType.NETWORK_OUTBOUND
|
|
413
|
+
}
|
|
414
|
+
if dangerous_combo.issubset(all_permissions):
|
|
415
|
+
findings.append({
|
|
416
|
+
"type": "dangerous_permission_combo",
|
|
417
|
+
"description": "Server has tools for both secret access and network outbound - potential data exfiltration",
|
|
418
|
+
"severity": "high"
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
return findings
|