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,321 @@
1
+ """MCP configuration scanner for static analysis of MCP server configs."""
2
+
3
+ import json
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import List, Dict, Any, Optional
7
+ from dataclasses import dataclass, field
8
+
9
+ import yaml
10
+
11
+ from agent_audit.scanners.base import BaseScanner, ScanResult
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @dataclass
17
+ class MCPServerConfig:
18
+ """Parsed MCP server configuration."""
19
+ name: str
20
+ command: Optional[str] = None
21
+ args: List[str] = field(default_factory=list)
22
+ url: Optional[str] = None
23
+ env: Dict[str, str] = field(default_factory=dict)
24
+ verified: bool = False
25
+ _line: int = 1 # Source line for error reporting
26
+ _source_file: str = ""
27
+
28
+
29
+ @dataclass
30
+ class MCPConfigScanResult(ScanResult):
31
+ """Result of MCP configuration scanning."""
32
+ servers: List[MCPServerConfig] = field(default_factory=list)
33
+ config_type: str = "unknown" # claude_desktop, docker_mcp, standard
34
+
35
+
36
+ class MCPConfigScanner(BaseScanner):
37
+ """
38
+ Static scanner for MCP configuration files.
39
+
40
+ Supports:
41
+ - Claude Desktop config (mcpServers)
42
+ - Docker MCP config (gateway.servers)
43
+ - Standard MCP config (servers[])
44
+ """
45
+
46
+ name = "MCP Config Scanner"
47
+
48
+ # Known config file names
49
+ CONFIG_FILENAMES = [
50
+ 'claude_desktop_config.json',
51
+ 'mcp.json',
52
+ 'mcp.yaml',
53
+ 'mcp.yml',
54
+ 'docker-mcp.json',
55
+ 'docker-mcp.yaml',
56
+ ]
57
+
58
+ # Trusted MCP server sources
59
+ TRUSTED_SOURCES = [
60
+ 'docker.io/mcp-catalog/',
61
+ 'ghcr.io/anthropics/',
62
+ 'ghcr.io/modelcontextprotocol/',
63
+ '@modelcontextprotocol/',
64
+ ]
65
+
66
+ def __init__(self, config_paths: Optional[List[Path]] = None):
67
+ """
68
+ Initialize the MCP config scanner.
69
+
70
+ Args:
71
+ config_paths: Specific config files to scan. If None,
72
+ auto-discovers config files.
73
+ """
74
+ self.config_paths = config_paths
75
+
76
+ def scan(self, path: Path) -> List[MCPConfigScanResult]:
77
+ """
78
+ Scan for MCP configuration files.
79
+
80
+ Args:
81
+ path: Directory or file to scan
82
+
83
+ Returns:
84
+ List of scan results
85
+ """
86
+ results = []
87
+ config_files = self._find_config_files(path)
88
+
89
+ for config_file in config_files:
90
+ result = self._scan_config_file(config_file)
91
+ if result:
92
+ results.append(result)
93
+
94
+ return results
95
+
96
+ def _find_config_files(self, path: Path) -> List[Path]:
97
+ """Find MCP configuration files."""
98
+ if self.config_paths:
99
+ return [p for p in self.config_paths if p.exists()]
100
+
101
+ if path.is_file():
102
+ if path.name in self.CONFIG_FILENAMES or self._looks_like_mcp_config(path):
103
+ return [path]
104
+ return []
105
+
106
+ config_files = []
107
+
108
+ # Check for known config file names
109
+ for filename in self.CONFIG_FILENAMES:
110
+ config_path = path / filename
111
+ if config_path.exists():
112
+ config_files.append(config_path)
113
+
114
+ # Check .claude directory
115
+ claude_dir = path / '.claude'
116
+ if claude_dir.exists():
117
+ for json_file in claude_dir.glob('*.json'):
118
+ if self._looks_like_mcp_config(json_file):
119
+ config_files.append(json_file)
120
+
121
+ return config_files
122
+
123
+ def _looks_like_mcp_config(self, file_path: Path) -> bool:
124
+ """Check if a file looks like an MCP config."""
125
+ try:
126
+ content = file_path.read_text(encoding='utf-8')
127
+
128
+ if file_path.suffix == '.json':
129
+ data = json.loads(content)
130
+ elif file_path.suffix in {'.yaml', '.yml'}:
131
+ data = yaml.safe_load(content)
132
+ else:
133
+ return False
134
+
135
+ if not isinstance(data, dict):
136
+ return False
137
+
138
+ # Check for MCP config indicators
139
+ return any(key in data for key in [
140
+ 'mcpServers', 'servers', 'gateway'
141
+ ])
142
+
143
+ except Exception:
144
+ return False
145
+
146
+ def _scan_config_file(self, file_path: Path) -> Optional[MCPConfigScanResult]:
147
+ """Scan a single config file."""
148
+ try:
149
+ content = file_path.read_text(encoding='utf-8')
150
+
151
+ if file_path.suffix == '.json':
152
+ data = json.loads(content)
153
+ elif file_path.suffix in {'.yaml', '.yml'}:
154
+ data = yaml.safe_load(content)
155
+ else:
156
+ logger.warning(f"Unsupported config format: {file_path}")
157
+ return None
158
+
159
+ if not isinstance(data, dict):
160
+ return None
161
+
162
+ # Detect config type and parse
163
+ servers, config_type = self._parse_config(data, str(file_path))
164
+
165
+ return MCPConfigScanResult(
166
+ source_file=str(file_path),
167
+ servers=servers,
168
+ config_type=config_type
169
+ )
170
+
171
+ except json.JSONDecodeError as e:
172
+ logger.warning(f"JSON parse error in {file_path}: {e}")
173
+ except yaml.YAMLError as e:
174
+ logger.warning(f"YAML parse error in {file_path}: {e}")
175
+ except Exception as e:
176
+ logger.warning(f"Error scanning {file_path}: {e}")
177
+
178
+ return None
179
+
180
+ def _parse_config(
181
+ self,
182
+ data: Dict[str, Any],
183
+ source_file: str
184
+ ) -> tuple[List[MCPServerConfig], str]:
185
+ """Parse MCP config and detect format."""
186
+ servers: List[MCPServerConfig] = []
187
+ config_type = "unknown"
188
+
189
+ # Claude Desktop format: { "mcpServers": { "name": { ... } } }
190
+ if 'mcpServers' in data:
191
+ config_type = "claude_desktop"
192
+ mcp_servers = data['mcpServers']
193
+ if isinstance(mcp_servers, dict):
194
+ for name, config in mcp_servers.items():
195
+ server = self._parse_server_config(name, config, source_file)
196
+ servers.append(server)
197
+
198
+ # Docker MCP format: { "gateway": { "servers": [ ... ] } }
199
+ elif 'gateway' in data and isinstance(data['gateway'], dict):
200
+ config_type = "docker_mcp"
201
+ gateway_servers = data['gateway'].get('servers', [])
202
+ if isinstance(gateway_servers, list):
203
+ for config in gateway_servers:
204
+ name = config.get('name', config.get('image', 'unknown'))
205
+ server = self._parse_server_config(name, config, source_file)
206
+ servers.append(server)
207
+
208
+ # Standard MCP format: { "servers": [ ... ] }
209
+ elif 'servers' in data:
210
+ config_type = "standard"
211
+ if isinstance(data['servers'], list):
212
+ for config in data['servers']:
213
+ name = config.get('name', 'unknown')
214
+ server = self._parse_server_config(name, config, source_file)
215
+ servers.append(server)
216
+ elif isinstance(data['servers'], dict):
217
+ for name, config in data['servers'].items():
218
+ server = self._parse_server_config(name, config, source_file)
219
+ servers.append(server)
220
+
221
+ return servers, config_type
222
+
223
+ def _parse_server_config(
224
+ self,
225
+ name: str,
226
+ config: Dict[str, Any],
227
+ source_file: str
228
+ ) -> MCPServerConfig:
229
+ """Parse a single server configuration."""
230
+ server = MCPServerConfig(
231
+ name=name,
232
+ command=config.get('command'),
233
+ args=config.get('args', []),
234
+ url=config.get('url'),
235
+ env=config.get('env', {}),
236
+ verified=self._is_verified_source(config),
237
+ _source_file=source_file
238
+ )
239
+
240
+ return server
241
+
242
+ def _is_verified_source(self, config: Dict[str, Any]) -> bool:
243
+ """
244
+ Check if a server configuration is from a trusted source.
245
+
246
+ Trusted sources include:
247
+ - Official MCP catalog Docker images
248
+ - Anthropic's GitHub packages
249
+ - ModelContextProtocol npm packages
250
+ """
251
+ # Check URL
252
+ url = config.get('url', '')
253
+ if url and any(url.startswith(src) for src in self.TRUSTED_SOURCES):
254
+ return True
255
+
256
+ # Check Docker image
257
+ image = config.get('image', '')
258
+ if image and any(image.startswith(src) for src in self.TRUSTED_SOURCES):
259
+ return True
260
+
261
+ # Check npm package in args
262
+ args = config.get('args', [])
263
+ for arg in args:
264
+ if isinstance(arg, str):
265
+ if any(src in arg for src in self.TRUSTED_SOURCES):
266
+ return True
267
+
268
+ return False
269
+
270
+ def get_dangerous_env_vars(self, server: MCPServerConfig) -> List[Dict[str, Any]]:
271
+ """
272
+ Find potentially dangerous environment variables.
273
+
274
+ Returns list of env vars that may contain credentials.
275
+ """
276
+ dangerous = []
277
+ sensitive_patterns = [
278
+ 'KEY', 'SECRET', 'TOKEN', 'PASSWORD', 'CREDENTIAL',
279
+ 'API_KEY', 'AUTH', 'PRIVATE'
280
+ ]
281
+
282
+ for key, value in server.env.items():
283
+ key_upper = key.upper()
284
+ if any(pattern in key_upper for pattern in sensitive_patterns):
285
+ # Check if it looks like a hardcoded secret
286
+ if isinstance(value, str) and len(value) > 10:
287
+ if not value.startswith('${') and not value.startswith('$'):
288
+ dangerous.append({
289
+ 'key': key,
290
+ 'value_preview': value[:10] + '...',
291
+ 'reason': 'Potential hardcoded credential'
292
+ })
293
+
294
+ return dangerous
295
+
296
+ def check_filesystem_access(self, server: MCPServerConfig) -> Dict[str, Any]:
297
+ """
298
+ Check for overly permissive filesystem access.
299
+
300
+ Returns analysis of filesystem access configuration.
301
+ """
302
+ result = {
303
+ 'has_root_access': False,
304
+ 'has_home_access': False,
305
+ 'accessible_paths': [],
306
+ 'risk_level': 'low'
307
+ }
308
+
309
+ # Check args for path access
310
+ for arg in server.args:
311
+ if isinstance(arg, str):
312
+ if arg == '/':
313
+ result['has_root_access'] = True
314
+ result['risk_level'] = 'critical'
315
+ elif arg.startswith('/home') or arg == '~' or arg.startswith('$HOME'):
316
+ result['has_home_access'] = True
317
+ result['risk_level'] = 'high' if result['risk_level'] != 'critical' else 'critical'
318
+ elif arg.startswith('/'):
319
+ result['accessible_paths'].append(arg)
320
+
321
+ return result