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,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
|