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.
Files changed (37) hide show
  1. agent_audit/__init__.py +3 -0
  2. agent_audit/__main__.py +13 -0
  3. agent_audit/cli/__init__.py +1 -0
  4. agent_audit/cli/commands/__init__.py +1 -0
  5. agent_audit/cli/commands/init.py +44 -0
  6. agent_audit/cli/commands/inspect.py +236 -0
  7. agent_audit/cli/commands/scan.py +329 -0
  8. agent_audit/cli/formatters/__init__.py +1 -0
  9. agent_audit/cli/formatters/json.py +138 -0
  10. agent_audit/cli/formatters/sarif.py +155 -0
  11. agent_audit/cli/formatters/terminal.py +221 -0
  12. agent_audit/cli/main.py +34 -0
  13. agent_audit/config/__init__.py +1 -0
  14. agent_audit/config/ignore.py +477 -0
  15. agent_audit/core_utils/__init__.py +1 -0
  16. agent_audit/models/__init__.py +18 -0
  17. agent_audit/models/finding.py +159 -0
  18. agent_audit/models/risk.py +77 -0
  19. agent_audit/models/tool.py +182 -0
  20. agent_audit/rules/__init__.py +6 -0
  21. agent_audit/rules/engine.py +503 -0
  22. agent_audit/rules/loader.py +160 -0
  23. agent_audit/scanners/__init__.py +5 -0
  24. agent_audit/scanners/base.py +32 -0
  25. agent_audit/scanners/config_scanner.py +390 -0
  26. agent_audit/scanners/mcp_config_scanner.py +321 -0
  27. agent_audit/scanners/mcp_inspector.py +421 -0
  28. agent_audit/scanners/python_scanner.py +544 -0
  29. agent_audit/scanners/secret_scanner.py +521 -0
  30. agent_audit/utils/__init__.py +21 -0
  31. agent_audit/utils/compat.py +98 -0
  32. agent_audit/utils/mcp_client.py +343 -0
  33. agent_audit/version.py +3 -0
  34. agent_audit-0.1.0.dist-info/METADATA +219 -0
  35. agent_audit-0.1.0.dist-info/RECORD +37 -0
  36. agent_audit-0.1.0.dist-info/WHEEL +4 -0
  37. 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