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,521 @@
|
|
|
1
|
+
"""Secret scanner for detecting hardcoded credentials."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import re
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Optional, Pattern, Tuple
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from agent_audit.scanners.base import BaseScanner, ScanResult
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class SecretMatch:
|
|
17
|
+
"""A detected secret."""
|
|
18
|
+
pattern_name: str
|
|
19
|
+
line_number: int
|
|
20
|
+
line_content: str
|
|
21
|
+
matched_text: str
|
|
22
|
+
start_col: int
|
|
23
|
+
end_col: int
|
|
24
|
+
severity: str # critical, high, medium
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class SecretScanResult(ScanResult):
|
|
29
|
+
"""Result of secret scanning."""
|
|
30
|
+
secrets: List[SecretMatch] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SecretScanner(BaseScanner):
|
|
34
|
+
"""
|
|
35
|
+
Regex-based secret detection scanner.
|
|
36
|
+
|
|
37
|
+
Detects:
|
|
38
|
+
- AWS access keys
|
|
39
|
+
- API keys (OpenAI, Anthropic, GitHub, etc.)
|
|
40
|
+
- Generic tokens and passwords
|
|
41
|
+
- Private keys
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
name = "Secret Scanner"
|
|
45
|
+
|
|
46
|
+
# Secret patterns with severity levels
|
|
47
|
+
SECRET_PATTERNS: List[Tuple[Pattern, str, str]] = [
|
|
48
|
+
# AWS
|
|
49
|
+
(re.compile(r'AKIA[0-9A-Z]{16}'), "AWS Access Key ID", "critical"),
|
|
50
|
+
(re.compile(r'(?<![A-Za-z0-9/+=])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])'),
|
|
51
|
+
"Potential AWS Secret Key", "high"),
|
|
52
|
+
|
|
53
|
+
# OpenAI
|
|
54
|
+
(re.compile(r'sk-[a-zA-Z0-9]{48,}'), "OpenAI API Key", "critical"),
|
|
55
|
+
(re.compile(r'sk-proj-[a-zA-Z0-9]{48,}'), "OpenAI Project API Key", "critical"),
|
|
56
|
+
|
|
57
|
+
# Anthropic
|
|
58
|
+
(re.compile(r'sk-ant-[a-zA-Z0-9-]{40,}'), "Anthropic API Key", "critical"),
|
|
59
|
+
|
|
60
|
+
# GitHub
|
|
61
|
+
(re.compile(r'ghp_[a-zA-Z0-9]{36}'), "GitHub Personal Access Token", "critical"),
|
|
62
|
+
(re.compile(r'gho_[a-zA-Z0-9]{36}'), "GitHub OAuth Token", "critical"),
|
|
63
|
+
(re.compile(r'ghs_[a-zA-Z0-9]{36}'), "GitHub App Token", "critical"),
|
|
64
|
+
(re.compile(r'ghr_[a-zA-Z0-9]{36}'), "GitHub Refresh Token", "critical"),
|
|
65
|
+
|
|
66
|
+
# Google
|
|
67
|
+
(re.compile(r'AIza[0-9A-Za-z\-_]{35}'), "Google API Key", "critical"),
|
|
68
|
+
|
|
69
|
+
# Stripe
|
|
70
|
+
(re.compile(r'sk_live_[a-zA-Z0-9]{24,}'), "Stripe Live Secret Key", "critical"),
|
|
71
|
+
(re.compile(r'sk_test_[a-zA-Z0-9]{24,}'), "Stripe Test Secret Key", "high"),
|
|
72
|
+
(re.compile(r'pk_live_[a-zA-Z0-9]{24,}'), "Stripe Live Publishable Key", "medium"),
|
|
73
|
+
|
|
74
|
+
# Generic patterns
|
|
75
|
+
(re.compile(r'(?i)(api[_-]?key|apikey)\s*[=:]\s*["\']?([a-zA-Z0-9_\-]{20,})["\']?'),
|
|
76
|
+
"Generic API Key", "high"),
|
|
77
|
+
(re.compile(r'(?i)(secret|password|passwd|pwd)\s*[=:]\s*["\']?([^\s"\']{8,})["\']?'),
|
|
78
|
+
"Generic Secret/Password", "high"),
|
|
79
|
+
(re.compile(r'(?i)(token|auth[_-]?token)\s*[=:]\s*["\']?([a-zA-Z0-9_\-]{20,})["\']?'),
|
|
80
|
+
"Generic Token", "high"),
|
|
81
|
+
|
|
82
|
+
# Private keys
|
|
83
|
+
(re.compile(r'-----BEGIN (?:RSA |DSA |EC |OPENSSH )?PRIVATE KEY-----'),
|
|
84
|
+
"Private Key Header", "critical"),
|
|
85
|
+
(re.compile(r'-----BEGIN PGP PRIVATE KEY BLOCK-----'),
|
|
86
|
+
"PGP Private Key", "critical"),
|
|
87
|
+
|
|
88
|
+
# Database connection strings
|
|
89
|
+
(re.compile(r'(?i)(?:mysql|postgres|postgresql|mongodb|redis)://[^\s"\']+:[^\s"\']+@'),
|
|
90
|
+
"Database Connection String with Credentials", "critical"),
|
|
91
|
+
|
|
92
|
+
# JWT secrets
|
|
93
|
+
(re.compile(r'(?i)jwt[_-]?secret\s*[=:]\s*["\']?([a-zA-Z0-9_\-]{16,})["\']?'),
|
|
94
|
+
"JWT Secret", "high"),
|
|
95
|
+
|
|
96
|
+
# Slack
|
|
97
|
+
(re.compile(r'xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*'),
|
|
98
|
+
"Slack Token", "critical"),
|
|
99
|
+
|
|
100
|
+
# Twilio
|
|
101
|
+
(re.compile(r'SK[a-f0-9]{32}'), "Twilio API Key", "critical"),
|
|
102
|
+
|
|
103
|
+
# SendGrid
|
|
104
|
+
(re.compile(r'SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}'),
|
|
105
|
+
"SendGrid API Key", "critical"),
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
# File extensions to scan
|
|
109
|
+
SCANNABLE_EXTENSIONS = {
|
|
110
|
+
'.py', '.js', '.ts', '.jsx', '.tsx', '.json', '.yaml', '.yml',
|
|
111
|
+
'.env', '.cfg', '.conf', '.config', '.ini', '.properties',
|
|
112
|
+
'.sh', '.bash', '.zsh', '.toml', '.xml', '.md', '.txt'
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# Files to always skip
|
|
116
|
+
SKIP_FILES = {
|
|
117
|
+
'package-lock.json', 'yarn.lock', 'poetry.lock',
|
|
118
|
+
'Cargo.lock', 'go.sum', 'pnpm-lock.yaml'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
exclude_paths: Optional[List[str]] = None,
|
|
124
|
+
custom_patterns: Optional[List[Tuple[str, str, str]]] = None
|
|
125
|
+
):
|
|
126
|
+
"""
|
|
127
|
+
Initialize the secret scanner.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
exclude_paths: Path patterns to exclude
|
|
131
|
+
custom_patterns: Additional patterns as (regex, name, severity) tuples
|
|
132
|
+
"""
|
|
133
|
+
self.exclude_paths = set(exclude_paths or [])
|
|
134
|
+
self.patterns = list(self.SECRET_PATTERNS)
|
|
135
|
+
|
|
136
|
+
# Add custom patterns
|
|
137
|
+
if custom_patterns:
|
|
138
|
+
for regex_str, name, severity in custom_patterns:
|
|
139
|
+
self.patterns.append((re.compile(regex_str), name, severity))
|
|
140
|
+
|
|
141
|
+
def scan(self, path: Path) -> List[SecretScanResult]:
|
|
142
|
+
"""
|
|
143
|
+
Scan for secrets in files.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
path: File or directory to scan
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
List of scan results
|
|
150
|
+
"""
|
|
151
|
+
results = []
|
|
152
|
+
files = self._find_files(path)
|
|
153
|
+
|
|
154
|
+
for file_path in files:
|
|
155
|
+
result = self._scan_file(file_path)
|
|
156
|
+
if result and result.secrets:
|
|
157
|
+
results.append(result)
|
|
158
|
+
|
|
159
|
+
return results
|
|
160
|
+
|
|
161
|
+
def _find_files(self, path: Path) -> List[Path]:
|
|
162
|
+
"""Find files to scan."""
|
|
163
|
+
if path.is_file():
|
|
164
|
+
if self._should_scan_file(path):
|
|
165
|
+
return [path]
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
files = []
|
|
169
|
+
for file_path in path.rglob('*'):
|
|
170
|
+
if not file_path.is_file():
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
if not self._should_scan_file(file_path):
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
# Check exclude patterns using glob matching
|
|
177
|
+
rel_path = str(file_path.relative_to(path))
|
|
178
|
+
if self._should_exclude(rel_path):
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
files.append(file_path)
|
|
182
|
+
|
|
183
|
+
return files
|
|
184
|
+
|
|
185
|
+
def _should_exclude(self, rel_path: str) -> bool:
|
|
186
|
+
"""Check if a relative path matches any exclude pattern."""
|
|
187
|
+
# Normalize path separators
|
|
188
|
+
normalized_path = rel_path.replace('\\', '/')
|
|
189
|
+
|
|
190
|
+
for pattern in self.exclude_paths:
|
|
191
|
+
normalized_pattern = pattern.replace('\\', '/')
|
|
192
|
+
|
|
193
|
+
# Simple substring matching (backward compatibility)
|
|
194
|
+
if normalized_pattern in normalized_path:
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
# Direct fnmatch for glob patterns
|
|
198
|
+
if fnmatch.fnmatch(normalized_path, normalized_pattern):
|
|
199
|
+
return True
|
|
200
|
+
|
|
201
|
+
# Handle "tests/**" style patterns
|
|
202
|
+
if normalized_pattern.endswith('/**'):
|
|
203
|
+
prefix = normalized_pattern[:-3]
|
|
204
|
+
if normalized_path.startswith(prefix + '/') or normalized_path == prefix:
|
|
205
|
+
return True
|
|
206
|
+
|
|
207
|
+
# Handle "**/test_*" style patterns
|
|
208
|
+
if normalized_pattern.startswith('**/'):
|
|
209
|
+
suffix_pattern = normalized_pattern[3:]
|
|
210
|
+
# Match against filename
|
|
211
|
+
filename = Path(normalized_path).name
|
|
212
|
+
if fnmatch.fnmatch(filename, suffix_pattern):
|
|
213
|
+
return True
|
|
214
|
+
# Match against any path segment
|
|
215
|
+
for part in Path(normalized_path).parts:
|
|
216
|
+
if fnmatch.fnmatch(part, suffix_pattern):
|
|
217
|
+
return True
|
|
218
|
+
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
def _should_scan_file(self, file_path: Path) -> bool:
|
|
222
|
+
"""Check if a file should be scanned."""
|
|
223
|
+
# Skip known non-secret files
|
|
224
|
+
if file_path.name in self.SKIP_FILES:
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
# Skip hidden directories
|
|
228
|
+
if any(part.startswith('.') and part not in {'.env'}
|
|
229
|
+
for part in file_path.parts[:-1]):
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
# Skip common non-source directories
|
|
233
|
+
skip_dirs = {'node_modules', 'venv', '.venv', '__pycache__',
|
|
234
|
+
'dist', 'build', '.git'}
|
|
235
|
+
if any(part in skip_dirs for part in file_path.parts):
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
# Check extension
|
|
239
|
+
if file_path.suffix.lower() in self.SCANNABLE_EXTENSIONS:
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
# Also scan .env files regardless of extension
|
|
243
|
+
if '.env' in file_path.name:
|
|
244
|
+
return True
|
|
245
|
+
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
def _scan_file(self, file_path: Path) -> Optional[SecretScanResult]:
|
|
249
|
+
"""Scan a single file for secrets."""
|
|
250
|
+
try:
|
|
251
|
+
content = file_path.read_text(encoding='utf-8', errors='ignore')
|
|
252
|
+
except Exception as e:
|
|
253
|
+
logger.warning(f"Error reading {file_path}: {e}")
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
secrets = []
|
|
257
|
+
lines = content.splitlines()
|
|
258
|
+
|
|
259
|
+
for line_num, line in enumerate(lines, start=1):
|
|
260
|
+
# Skip empty lines and comments
|
|
261
|
+
stripped = line.strip()
|
|
262
|
+
if not stripped or stripped.startswith('#') or stripped.startswith('//'):
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
# Check each pattern
|
|
266
|
+
for pattern, name, severity in self.patterns:
|
|
267
|
+
for match in pattern.finditer(line):
|
|
268
|
+
# Filter out false positives
|
|
269
|
+
if self._is_false_positive(line, match, file_path):
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
secret = SecretMatch(
|
|
273
|
+
pattern_name=name,
|
|
274
|
+
line_number=line_num,
|
|
275
|
+
line_content=self._mask_secret(line, match),
|
|
276
|
+
matched_text=self._mask_match(match.group()),
|
|
277
|
+
start_col=match.start(),
|
|
278
|
+
end_col=match.end(),
|
|
279
|
+
severity=severity
|
|
280
|
+
)
|
|
281
|
+
secrets.append(secret)
|
|
282
|
+
|
|
283
|
+
return SecretScanResult(
|
|
284
|
+
source_file=str(file_path),
|
|
285
|
+
secrets=secrets
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def _is_false_positive(
|
|
289
|
+
self,
|
|
290
|
+
line: str,
|
|
291
|
+
match: re.Match,
|
|
292
|
+
file_path: Path
|
|
293
|
+
) -> bool:
|
|
294
|
+
"""
|
|
295
|
+
Check if a match is likely a false positive.
|
|
296
|
+
|
|
297
|
+
Filters out:
|
|
298
|
+
- Example/placeholder values
|
|
299
|
+
- Test fixtures
|
|
300
|
+
- Documentation
|
|
301
|
+
- Variable/class/function names containing keywords
|
|
302
|
+
- Environment variable lookups
|
|
303
|
+
- Secure wrappers (SecretStr, etc.)
|
|
304
|
+
"""
|
|
305
|
+
matched_text = match.group().lower()
|
|
306
|
+
line_lower = line.lower()
|
|
307
|
+
stripped = line.strip()
|
|
308
|
+
|
|
309
|
+
# Common placeholder patterns
|
|
310
|
+
placeholders = [
|
|
311
|
+
'example', 'placeholder', 'your_', 'my_', 'xxx',
|
|
312
|
+
'test', 'fake', 'dummy', 'sample', 'demo', '<your',
|
|
313
|
+
'insert_', 'replace_', 'changeme', 'undefined'
|
|
314
|
+
]
|
|
315
|
+
if any(p in matched_text for p in placeholders):
|
|
316
|
+
return True
|
|
317
|
+
|
|
318
|
+
# Check if this looks like documentation
|
|
319
|
+
if '# example' in line_lower or '// example' in line_lower:
|
|
320
|
+
return True
|
|
321
|
+
|
|
322
|
+
# Check file path for test/example indicators
|
|
323
|
+
path_str = str(file_path).lower()
|
|
324
|
+
if any(p in path_str for p in ['test', 'example', 'fixture', 'mock', 'sample']):
|
|
325
|
+
return True
|
|
326
|
+
|
|
327
|
+
# Skip class definitions (class FooTokenBar:)
|
|
328
|
+
if stripped.startswith('class '):
|
|
329
|
+
return True
|
|
330
|
+
|
|
331
|
+
# Skip function definitions (def get_api_key(...):)
|
|
332
|
+
if stripped.startswith('def '):
|
|
333
|
+
return True
|
|
334
|
+
|
|
335
|
+
# Skip import statements
|
|
336
|
+
if stripped.startswith('import ') or stripped.startswith('from '):
|
|
337
|
+
return True
|
|
338
|
+
|
|
339
|
+
# Skip type annotations (variable: SecretStr)
|
|
340
|
+
if re.search(r':\s*(Optional\[)?SecretStr', line):
|
|
341
|
+
return True
|
|
342
|
+
|
|
343
|
+
# Check for environment variable lookups - value is not hardcoded
|
|
344
|
+
env_patterns = [
|
|
345
|
+
r'os\.environ\.get\s*\(',
|
|
346
|
+
r'os\.environ\[',
|
|
347
|
+
r'os\.getenv\s*\(',
|
|
348
|
+
r'getenv\s*\(',
|
|
349
|
+
r'environ\.get\s*\(',
|
|
350
|
+
r'settings\.\w+', # e.g., settings.API_KEY
|
|
351
|
+
r'config\.\w+', # e.g., config.api_key
|
|
352
|
+
r'Config\.\w+',
|
|
353
|
+
r'get_from_\w+\s*\(', # get_from_env, get_from_dict_or_env, etc.
|
|
354
|
+
]
|
|
355
|
+
for pattern in env_patterns:
|
|
356
|
+
if re.search(pattern, line):
|
|
357
|
+
return True
|
|
358
|
+
|
|
359
|
+
# Check for secure wrappers - value is wrapped, not exposed
|
|
360
|
+
secure_wrappers = [
|
|
361
|
+
r'SecretStr\s*\(',
|
|
362
|
+
r'Secret\s*\(',
|
|
363
|
+
r'SecureString\s*\(',
|
|
364
|
+
r'Field\s*\([^)]*secret\s*=\s*True',
|
|
365
|
+
]
|
|
366
|
+
for pattern in secure_wrappers:
|
|
367
|
+
if re.search(pattern, line, re.IGNORECASE):
|
|
368
|
+
return True
|
|
369
|
+
|
|
370
|
+
# For generic patterns (api_key=, token=, etc.), verify the right side
|
|
371
|
+
# is a string literal that looks like a real secret, not a variable
|
|
372
|
+
if self._is_generic_pattern_match(match):
|
|
373
|
+
if not self._has_literal_secret_value(line, match):
|
|
374
|
+
return True
|
|
375
|
+
|
|
376
|
+
# Check if match looks like a PascalCase class/type name (e.g., ConversationTokenBufferMemory)
|
|
377
|
+
matched_text_raw = match.group()
|
|
378
|
+
if self._looks_like_class_name(matched_text_raw, line):
|
|
379
|
+
return True
|
|
380
|
+
|
|
381
|
+
# Environment variable references (not actual values)
|
|
382
|
+
if '${' in line or '$(' in line:
|
|
383
|
+
if matched_text in line[match.start():match.end()+5]:
|
|
384
|
+
# Check if the match is inside a variable reference
|
|
385
|
+
before = line[:match.start()]
|
|
386
|
+
if '${' in before[-10:] or '$(' in before[-10:]:
|
|
387
|
+
return True
|
|
388
|
+
|
|
389
|
+
return False
|
|
390
|
+
|
|
391
|
+
def _looks_like_class_name(self, matched_text: str, line: str) -> bool:
|
|
392
|
+
"""
|
|
393
|
+
Check if the matched text looks like a class/type name rather than a secret.
|
|
394
|
+
|
|
395
|
+
PascalCase identifiers with multiple capital letters are likely class names,
|
|
396
|
+
not secrets. Real secrets don't follow PascalCase naming conventions.
|
|
397
|
+
"""
|
|
398
|
+
# Check for PascalCase pattern: starts with capital, has multiple capitals
|
|
399
|
+
# Also allow all-alpha strings that follow PascalCase (no numbers, no special chars)
|
|
400
|
+
text_to_check = matched_text
|
|
401
|
+
|
|
402
|
+
# If match contains '=' (from AWS pattern [A-Za-z0-9/+=]), extract the part after '='
|
|
403
|
+
# This handles cases like 'factory=PairwiseStringResultOutputParser'
|
|
404
|
+
if '=' in matched_text:
|
|
405
|
+
parts = matched_text.split('=')
|
|
406
|
+
# Check if the part after = looks like a class name
|
|
407
|
+
text_to_check = parts[-1]
|
|
408
|
+
|
|
409
|
+
if re.match(r'^[A-Z][a-zA-Z]+$', text_to_check):
|
|
410
|
+
# Count capital letters - class names typically have several
|
|
411
|
+
capital_count = sum(1 for c in text_to_check if c.isupper())
|
|
412
|
+
if capital_count >= 2:
|
|
413
|
+
return True
|
|
414
|
+
|
|
415
|
+
# Check for common class name suffixes
|
|
416
|
+
class_suffixes = [
|
|
417
|
+
'Memory', 'Buffer', 'Parser', 'Handler', 'Manager',
|
|
418
|
+
'Factory', 'Builder', 'Wrapper', 'Provider', 'Service',
|
|
419
|
+
'Client', 'Server', 'Controller', 'Processor', 'Validator'
|
|
420
|
+
]
|
|
421
|
+
if any(text_to_check.endswith(suffix) for suffix in class_suffixes):
|
|
422
|
+
return True
|
|
423
|
+
|
|
424
|
+
return False
|
|
425
|
+
|
|
426
|
+
def _is_generic_pattern_match(self, match: re.Match) -> bool:
|
|
427
|
+
"""Check if this match is from a generic pattern (api_key=, token=, etc.)."""
|
|
428
|
+
pattern_str = match.re.pattern
|
|
429
|
+
# Generic patterns have the keyword group followed by = or :
|
|
430
|
+
generic_indicators = [
|
|
431
|
+
r'\(api[_-]?key|apikey\)',
|
|
432
|
+
r'\(secret|password|passwd|pwd\)',
|
|
433
|
+
r'\(token|auth[_-]?token\)',
|
|
434
|
+
r'jwt[_-]?secret',
|
|
435
|
+
]
|
|
436
|
+
for indicator in generic_indicators:
|
|
437
|
+
if indicator in pattern_str.lower():
|
|
438
|
+
return True
|
|
439
|
+
return False
|
|
440
|
+
|
|
441
|
+
def _has_literal_secret_value(self, line: str, match: re.Match) -> bool:
|
|
442
|
+
"""
|
|
443
|
+
Check if the matched assignment has a string literal value that looks like a secret.
|
|
444
|
+
|
|
445
|
+
Returns True if it looks like a real hardcoded secret, False if it's likely
|
|
446
|
+
a variable reference, function call, or non-secret value.
|
|
447
|
+
"""
|
|
448
|
+
# Extract the part after the = or :
|
|
449
|
+
match_text = match.group()
|
|
450
|
+
eq_pos = -1
|
|
451
|
+
for sep in ['=', ':']:
|
|
452
|
+
pos = match_text.find(sep)
|
|
453
|
+
if pos != -1:
|
|
454
|
+
eq_pos = pos
|
|
455
|
+
break
|
|
456
|
+
|
|
457
|
+
if eq_pos == -1:
|
|
458
|
+
return True # Not an assignment pattern, let other checks handle it
|
|
459
|
+
|
|
460
|
+
# Get the value part (after = or :)
|
|
461
|
+
value_part = match_text[eq_pos + 1:].strip()
|
|
462
|
+
|
|
463
|
+
# Remove leading quotes
|
|
464
|
+
if value_part.startswith('"') or value_part.startswith("'"):
|
|
465
|
+
value_part = value_part[1:]
|
|
466
|
+
if value_part.endswith('"') or value_part.endswith("'"):
|
|
467
|
+
value_part = value_part[:-1]
|
|
468
|
+
|
|
469
|
+
# Check if value is empty or too short
|
|
470
|
+
if len(value_part) < 8:
|
|
471
|
+
return False
|
|
472
|
+
|
|
473
|
+
# Check if value looks like a variable name (all lowercase/uppercase, underscores)
|
|
474
|
+
if re.match(r'^[a-z_][a-z0-9_]*$', value_part) and not any(c.isdigit() for c in value_part[-4:]):
|
|
475
|
+
return False
|
|
476
|
+
|
|
477
|
+
# Check if it's a common non-secret pattern
|
|
478
|
+
non_secret_patterns = [
|
|
479
|
+
r'^[A-Z_]+$', # All caps constant name like API_KEY
|
|
480
|
+
r'^None$',
|
|
481
|
+
r'^null$',
|
|
482
|
+
r'^""$',
|
|
483
|
+
r"^''$",
|
|
484
|
+
r'^\.\.\.$', # Ellipsis
|
|
485
|
+
]
|
|
486
|
+
for pattern in non_secret_patterns:
|
|
487
|
+
if re.match(pattern, value_part, re.IGNORECASE):
|
|
488
|
+
return False
|
|
489
|
+
|
|
490
|
+
# Check for mixed characters (letters, numbers, special chars) typical of secrets
|
|
491
|
+
has_letters = bool(re.search(r'[a-zA-Z]', value_part))
|
|
492
|
+
has_numbers = bool(re.search(r'[0-9]', value_part))
|
|
493
|
+
has_special = bool(re.search(r'[-_/+=]', value_part))
|
|
494
|
+
|
|
495
|
+
# Real secrets typically have a mix of character types
|
|
496
|
+
char_types = sum([has_letters, has_numbers, has_special])
|
|
497
|
+
if char_types < 2 and len(value_part) < 20:
|
|
498
|
+
return False
|
|
499
|
+
|
|
500
|
+
return True
|
|
501
|
+
|
|
502
|
+
def _mask_secret(self, line: str, match: re.Match) -> str:
|
|
503
|
+
"""Mask the secret value in a line for safe display."""
|
|
504
|
+
start = match.start()
|
|
505
|
+
end = match.end()
|
|
506
|
+
matched_len = end - start
|
|
507
|
+
|
|
508
|
+
if matched_len <= 8:
|
|
509
|
+
masked = '*' * matched_len
|
|
510
|
+
else:
|
|
511
|
+
# Show first and last 4 chars
|
|
512
|
+
original = match.group()
|
|
513
|
+
masked = original[:4] + '*' * (matched_len - 8) + original[-4:]
|
|
514
|
+
|
|
515
|
+
return line[:start] + masked + line[end:]
|
|
516
|
+
|
|
517
|
+
def _mask_match(self, text: str) -> str:
|
|
518
|
+
"""Mask a matched secret for display."""
|
|
519
|
+
if len(text) <= 8:
|
|
520
|
+
return '*' * len(text)
|
|
521
|
+
return text[:4] + '*' * (len(text) - 8) + text[-4:]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Utilities for agent-audit."""
|
|
2
|
+
|
|
3
|
+
from agent_audit.utils.compat import (
|
|
4
|
+
IS_WINDOWS,
|
|
5
|
+
IS_MACOS,
|
|
6
|
+
IS_LINUX,
|
|
7
|
+
normalize_path,
|
|
8
|
+
home_config_dir,
|
|
9
|
+
get_subprocess_creation_flags,
|
|
10
|
+
setup_event_loop_policy,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"IS_WINDOWS",
|
|
15
|
+
"IS_MACOS",
|
|
16
|
+
"IS_LINUX",
|
|
17
|
+
"normalize_path",
|
|
18
|
+
"home_config_dir",
|
|
19
|
+
"get_subprocess_creation_flags",
|
|
20
|
+
"setup_event_loop_policy",
|
|
21
|
+
]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Cross-platform compatibility utilities.
|
|
2
|
+
|
|
3
|
+
This module provides platform detection and path handling utilities
|
|
4
|
+
to ensure the tool works correctly on Windows, macOS, and Linux.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Union
|
|
10
|
+
|
|
11
|
+
# Platform detection constants
|
|
12
|
+
IS_WINDOWS = sys.platform == "win32"
|
|
13
|
+
IS_MACOS = sys.platform == "darwin"
|
|
14
|
+
IS_LINUX = sys.platform.startswith("linux")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def normalize_path(path: Union[str, Path]) -> str:
|
|
18
|
+
"""
|
|
19
|
+
Normalize a path to use forward slashes consistently.
|
|
20
|
+
|
|
21
|
+
This ensures that file paths stored in findings and reports
|
|
22
|
+
use consistent forward slashes across all platforms, making
|
|
23
|
+
output deterministic and comparable.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
path: Path to normalize (string or Path object)
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Normalized path string with forward slashes
|
|
30
|
+
"""
|
|
31
|
+
path_str = str(path)
|
|
32
|
+
# Always use forward slashes for consistency in outputs
|
|
33
|
+
return path_str.replace("\\", "/")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def home_config_dir() -> Path:
|
|
37
|
+
"""
|
|
38
|
+
Get the appropriate configuration directory for the current platform.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
- Windows: %APPDATA%/agent-audit
|
|
42
|
+
- macOS: ~/Library/Application Support/agent-audit
|
|
43
|
+
- Linux: ~/.config/agent-audit
|
|
44
|
+
|
|
45
|
+
Falls back to ~/.agent-audit if the platform-specific directory
|
|
46
|
+
cannot be determined.
|
|
47
|
+
"""
|
|
48
|
+
if IS_WINDOWS:
|
|
49
|
+
# Use APPDATA on Windows
|
|
50
|
+
import os
|
|
51
|
+
appdata = os.environ.get("APPDATA")
|
|
52
|
+
if appdata:
|
|
53
|
+
return Path(appdata) / "agent-audit"
|
|
54
|
+
# Fallback to home directory
|
|
55
|
+
return Path.home() / ".agent-audit"
|
|
56
|
+
|
|
57
|
+
elif IS_MACOS:
|
|
58
|
+
# Use Library/Application Support on macOS
|
|
59
|
+
return Path.home() / "Library" / "Application Support" / "agent-audit"
|
|
60
|
+
|
|
61
|
+
else:
|
|
62
|
+
# Use XDG_CONFIG_HOME on Linux, fallback to ~/.config
|
|
63
|
+
import os
|
|
64
|
+
xdg_config = os.environ.get("XDG_CONFIG_HOME")
|
|
65
|
+
if xdg_config:
|
|
66
|
+
return Path(xdg_config) / "agent-audit"
|
|
67
|
+
return Path.home() / ".config" / "agent-audit"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_subprocess_creation_flags() -> int:
|
|
71
|
+
"""
|
|
72
|
+
Get the appropriate subprocess creation flags for the current platform.
|
|
73
|
+
|
|
74
|
+
On Windows, returns CREATE_NO_WINDOW to prevent console windows from
|
|
75
|
+
appearing when running background processes. On other platforms, returns 0.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Creation flags for subprocess.Popen or asyncio.create_subprocess_exec
|
|
79
|
+
"""
|
|
80
|
+
if IS_WINDOWS:
|
|
81
|
+
# CREATE_NO_WINDOW = 0x08000000
|
|
82
|
+
# Prevents console window from appearing
|
|
83
|
+
return 0x08000000
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def setup_event_loop_policy():
|
|
88
|
+
"""
|
|
89
|
+
Configure the asyncio event loop policy for the current platform.
|
|
90
|
+
|
|
91
|
+
On Windows, sets WindowsSelectorEventLoopPolicy to avoid issues with
|
|
92
|
+
ProactorEventLoop and subprocesses. This should be called early in
|
|
93
|
+
the application startup.
|
|
94
|
+
"""
|
|
95
|
+
if IS_WINDOWS:
|
|
96
|
+
import asyncio
|
|
97
|
+
# WindowsSelectorEventLoopPolicy is more compatible with subprocess operations
|
|
98
|
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|