tweek 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.
- tweek/__init__.py +16 -0
- tweek/cli.py +3390 -0
- tweek/cli_helpers.py +193 -0
- tweek/config/__init__.py +13 -0
- tweek/config/allowed_dirs.yaml +23 -0
- tweek/config/manager.py +1064 -0
- tweek/config/patterns.yaml +751 -0
- tweek/config/tiers.yaml +129 -0
- tweek/diagnostics.py +589 -0
- tweek/hooks/__init__.py +1 -0
- tweek/hooks/pre_tool_use.py +861 -0
- tweek/integrations/__init__.py +3 -0
- tweek/integrations/moltbot.py +243 -0
- tweek/licensing.py +398 -0
- tweek/logging/__init__.py +9 -0
- tweek/logging/bundle.py +350 -0
- tweek/logging/json_logger.py +150 -0
- tweek/logging/security_log.py +745 -0
- tweek/mcp/__init__.py +24 -0
- tweek/mcp/approval.py +456 -0
- tweek/mcp/approval_cli.py +356 -0
- tweek/mcp/clients/__init__.py +37 -0
- tweek/mcp/clients/chatgpt.py +112 -0
- tweek/mcp/clients/claude_desktop.py +203 -0
- tweek/mcp/clients/gemini.py +178 -0
- tweek/mcp/proxy.py +667 -0
- tweek/mcp/screening.py +175 -0
- tweek/mcp/server.py +317 -0
- tweek/platform/__init__.py +131 -0
- tweek/plugins/__init__.py +835 -0
- tweek/plugins/base.py +1080 -0
- tweek/plugins/compliance/__init__.py +30 -0
- tweek/plugins/compliance/gdpr.py +333 -0
- tweek/plugins/compliance/gov.py +324 -0
- tweek/plugins/compliance/hipaa.py +285 -0
- tweek/plugins/compliance/legal.py +322 -0
- tweek/plugins/compliance/pci.py +361 -0
- tweek/plugins/compliance/soc2.py +275 -0
- tweek/plugins/detectors/__init__.py +30 -0
- tweek/plugins/detectors/continue_dev.py +206 -0
- tweek/plugins/detectors/copilot.py +254 -0
- tweek/plugins/detectors/cursor.py +192 -0
- tweek/plugins/detectors/moltbot.py +205 -0
- tweek/plugins/detectors/windsurf.py +214 -0
- tweek/plugins/git_discovery.py +395 -0
- tweek/plugins/git_installer.py +491 -0
- tweek/plugins/git_lockfile.py +338 -0
- tweek/plugins/git_registry.py +503 -0
- tweek/plugins/git_security.py +482 -0
- tweek/plugins/providers/__init__.py +30 -0
- tweek/plugins/providers/anthropic.py +181 -0
- tweek/plugins/providers/azure_openai.py +289 -0
- tweek/plugins/providers/bedrock.py +248 -0
- tweek/plugins/providers/google.py +197 -0
- tweek/plugins/providers/openai.py +230 -0
- tweek/plugins/scope.py +130 -0
- tweek/plugins/screening/__init__.py +26 -0
- tweek/plugins/screening/llm_reviewer.py +149 -0
- tweek/plugins/screening/pattern_matcher.py +273 -0
- tweek/plugins/screening/rate_limiter.py +174 -0
- tweek/plugins/screening/session_analyzer.py +159 -0
- tweek/proxy/__init__.py +302 -0
- tweek/proxy/addon.py +223 -0
- tweek/proxy/interceptor.py +313 -0
- tweek/proxy/server.py +315 -0
- tweek/sandbox/__init__.py +71 -0
- tweek/sandbox/executor.py +382 -0
- tweek/sandbox/linux.py +278 -0
- tweek/sandbox/profile_generator.py +323 -0
- tweek/screening/__init__.py +13 -0
- tweek/screening/context.py +81 -0
- tweek/security/__init__.py +22 -0
- tweek/security/llm_reviewer.py +348 -0
- tweek/security/rate_limiter.py +682 -0
- tweek/security/secret_scanner.py +506 -0
- tweek/security/session_analyzer.py +600 -0
- tweek/vault/__init__.py +40 -0
- tweek/vault/cross_platform.py +251 -0
- tweek/vault/keychain.py +288 -0
- tweek-0.1.0.dist-info/METADATA +335 -0
- tweek-0.1.0.dist-info/RECORD +85 -0
- tweek-0.1.0.dist-info/WHEEL +5 -0
- tweek-0.1.0.dist-info/entry_points.txt +25 -0
- tweek-0.1.0.dist-info/licenses/LICENSE +190 -0
- tweek-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Tweek Secret Scanner
|
|
4
|
+
|
|
5
|
+
Scans configuration files for hardcoded secrets and credentials.
|
|
6
|
+
Enforces environment-variable-only secrets policy.
|
|
7
|
+
|
|
8
|
+
Based on moltbot's secret-guard security hardening initiative.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import stat
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional, List, Dict, Any, Tuple
|
|
18
|
+
import json
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SecretType(Enum):
|
|
23
|
+
"""Types of secrets that can be detected."""
|
|
24
|
+
API_KEY = "api_key"
|
|
25
|
+
PASSWORD = "password"
|
|
26
|
+
TOKEN = "token"
|
|
27
|
+
PRIVATE_KEY = "private_key"
|
|
28
|
+
CONNECTION_STRING = "connection_string"
|
|
29
|
+
AWS_CREDENTIAL = "aws_credential"
|
|
30
|
+
OAUTH_SECRET = "oauth_secret"
|
|
31
|
+
WEBHOOK_SECRET = "webhook_secret"
|
|
32
|
+
ENCRYPTION_KEY = "encryption_key"
|
|
33
|
+
CERTIFICATE = "certificate"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class SecretFinding:
|
|
38
|
+
"""A detected secret in a configuration file."""
|
|
39
|
+
file_path: Path
|
|
40
|
+
secret_type: SecretType
|
|
41
|
+
key_name: str
|
|
42
|
+
line_number: Optional[int] = None
|
|
43
|
+
context: Optional[str] = None # Redacted preview
|
|
44
|
+
severity: str = "high"
|
|
45
|
+
recommendation: str = ""
|
|
46
|
+
|
|
47
|
+
def __post_init__(self):
|
|
48
|
+
if not self.recommendation:
|
|
49
|
+
env_var = self._suggest_env_var()
|
|
50
|
+
self.recommendation = f"Move to environment variable: {env_var}"
|
|
51
|
+
|
|
52
|
+
def _suggest_env_var(self) -> str:
|
|
53
|
+
"""Suggest an environment variable name."""
|
|
54
|
+
# Convert key name to UPPER_SNAKE_CASE
|
|
55
|
+
name = re.sub(r'[^a-zA-Z0-9]', '_', self.key_name)
|
|
56
|
+
name = re.sub(r'_+', '_', name).strip('_').upper()
|
|
57
|
+
return f"TWEEK_{name}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class ScanResult:
|
|
62
|
+
"""Result of scanning for secrets."""
|
|
63
|
+
findings: List[SecretFinding] = field(default_factory=list)
|
|
64
|
+
files_scanned: int = 0
|
|
65
|
+
files_with_secrets: int = 0
|
|
66
|
+
permissions_fixed: List[Path] = field(default_factory=list)
|
|
67
|
+
errors: List[str] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def is_clean(self) -> bool:
|
|
71
|
+
return len(self.findings) == 0
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def has_critical(self) -> bool:
|
|
75
|
+
return any(f.severity == "critical" for f in self.findings)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SecretScanner:
|
|
79
|
+
"""
|
|
80
|
+
Scanner for detecting hardcoded secrets in configuration files.
|
|
81
|
+
|
|
82
|
+
Checks for:
|
|
83
|
+
- API keys and tokens
|
|
84
|
+
- Passwords and secrets
|
|
85
|
+
- Private keys and certificates
|
|
86
|
+
- Connection strings
|
|
87
|
+
- AWS/cloud credentials
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
# Patterns for detecting secrets (key patterns)
|
|
91
|
+
SECRET_KEY_PATTERNS = [
|
|
92
|
+
# API Keys
|
|
93
|
+
(r'(?i)(api[_-]?key|apikey)', SecretType.API_KEY),
|
|
94
|
+
(r'(?i)(secret[_-]?key|secretkey)', SecretType.API_KEY),
|
|
95
|
+
(r'(?i)(access[_-]?key|accesskey)', SecretType.API_KEY),
|
|
96
|
+
|
|
97
|
+
# Passwords
|
|
98
|
+
(r'(?i)(password|passwd|pwd)', SecretType.PASSWORD),
|
|
99
|
+
(r'(?i)(secret)', SecretType.PASSWORD),
|
|
100
|
+
|
|
101
|
+
# Tokens
|
|
102
|
+
(r'(?i)(token|auth[_-]?token|bearer)', SecretType.TOKEN),
|
|
103
|
+
(r'(?i)(jwt|refresh[_-]?token)', SecretType.TOKEN),
|
|
104
|
+
|
|
105
|
+
# Private Keys
|
|
106
|
+
(r'(?i)(private[_-]?key|priv[_-]?key)', SecretType.PRIVATE_KEY),
|
|
107
|
+
(r'(?i)(ssh[_-]?key|rsa[_-]?key)', SecretType.PRIVATE_KEY),
|
|
108
|
+
|
|
109
|
+
# Connection Strings
|
|
110
|
+
(r'(?i)(connection[_-]?string|conn[_-]?str)', SecretType.CONNECTION_STRING),
|
|
111
|
+
(r'(?i)(database[_-]?url|db[_-]?url)', SecretType.CONNECTION_STRING),
|
|
112
|
+
(r'(?i)(mongodb|postgres|mysql|redis).*url', SecretType.CONNECTION_STRING),
|
|
113
|
+
|
|
114
|
+
# AWS
|
|
115
|
+
(r'(?i)(aws[_-]?secret)', SecretType.AWS_CREDENTIAL),
|
|
116
|
+
(r'(?i)(aws[_-]?access[_-]?key[_-]?id)', SecretType.AWS_CREDENTIAL),
|
|
117
|
+
|
|
118
|
+
# OAuth
|
|
119
|
+
(r'(?i)(client[_-]?secret|oauth[_-]?secret)', SecretType.OAUTH_SECRET),
|
|
120
|
+
(r'(?i)(app[_-]?secret)', SecretType.OAUTH_SECRET),
|
|
121
|
+
|
|
122
|
+
# Webhooks
|
|
123
|
+
(r'(?i)(webhook[_-]?secret|signing[_-]?secret)', SecretType.WEBHOOK_SECRET),
|
|
124
|
+
|
|
125
|
+
# Encryption
|
|
126
|
+
(r'(?i)(encryption[_-]?key|encrypt[_-]?key)', SecretType.ENCRYPTION_KEY),
|
|
127
|
+
(r'(?i)(aes[_-]?key|cipher[_-]?key)', SecretType.ENCRYPTION_KEY),
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
# Patterns for detecting secret values (value patterns)
|
|
131
|
+
SECRET_VALUE_PATTERNS = [
|
|
132
|
+
# AWS Access Key ID (starts with AKIA, ABIA, ACCA, ASIA)
|
|
133
|
+
(r'(?:A3T[A-Z0-9]|AKIA|ABIA|ACCA|ASIA)[A-Z0-9]{16}', SecretType.AWS_CREDENTIAL, "critical"),
|
|
134
|
+
|
|
135
|
+
# AWS Secret Access Key (40 char base64-ish)
|
|
136
|
+
(r'(?i)aws.*[\'"][A-Za-z0-9/+=]{40}[\'"]', SecretType.AWS_CREDENTIAL, "critical"),
|
|
137
|
+
|
|
138
|
+
# Private key markers
|
|
139
|
+
(r'-----BEGIN (?:RSA |DSA |EC |OPENSSH )?PRIVATE KEY-----', SecretType.PRIVATE_KEY, "critical"),
|
|
140
|
+
|
|
141
|
+
# JWT tokens
|
|
142
|
+
(r'eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*', SecretType.TOKEN, "high"),
|
|
143
|
+
|
|
144
|
+
# GitHub tokens
|
|
145
|
+
(r'gh[pousr]_[A-Za-z0-9_]{36,}', SecretType.TOKEN, "critical"),
|
|
146
|
+
|
|
147
|
+
# Generic API key patterns (long alphanumeric strings)
|
|
148
|
+
(r'(?i)(?:api[_-]?key|token|secret)[\'"\s:=]+[\'"]?[A-Za-z0-9_-]{32,}[\'"]?', SecretType.API_KEY, "high"),
|
|
149
|
+
|
|
150
|
+
# Slack tokens
|
|
151
|
+
(r'xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*', SecretType.TOKEN, "critical"),
|
|
152
|
+
|
|
153
|
+
# Generic high-entropy strings that look like secrets
|
|
154
|
+
(r'[\'"][A-Za-z0-9+/]{40,}={0,2}[\'"]', SecretType.API_KEY, "medium"),
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
# Files that should have restricted permissions (chmod 600)
|
|
158
|
+
SENSITIVE_FILES = [
|
|
159
|
+
"oauth.json",
|
|
160
|
+
"auth-profiles.json",
|
|
161
|
+
"credentials.json",
|
|
162
|
+
"secrets.yaml",
|
|
163
|
+
"secrets.json",
|
|
164
|
+
".env",
|
|
165
|
+
"license.key",
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
# Environment variable reference patterns (these are OK)
|
|
169
|
+
ENV_VAR_PATTERNS = [
|
|
170
|
+
r'\$\{[A-Z_][A-Z0-9_]*\}', # ${VAR_NAME}
|
|
171
|
+
r'\$[A-Z_][A-Z0-9_]*', # $VAR_NAME
|
|
172
|
+
r'env\([\'"]?[A-Z_][A-Z0-9_]*[\'"]?\)', # env("VAR")
|
|
173
|
+
r'os\.environ\[[\'"][A-Z_][A-Z0-9_]*[\'"]\]', # os.environ["VAR"]
|
|
174
|
+
r'process\.env\.[A-Z_][A-Z0-9_]*', # process.env.VAR
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
def __init__(self, enforce_permissions: bool = True):
|
|
178
|
+
"""
|
|
179
|
+
Initialize the secret scanner.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
enforce_permissions: If True, fix permissions on sensitive files
|
|
183
|
+
"""
|
|
184
|
+
self.enforce_permissions = enforce_permissions
|
|
185
|
+
|
|
186
|
+
def scan_file(self, file_path: Path) -> List[SecretFinding]:
|
|
187
|
+
"""
|
|
188
|
+
Scan a single file for hardcoded secrets.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
file_path: Path to the file to scan
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
List of secret findings
|
|
195
|
+
"""
|
|
196
|
+
findings = []
|
|
197
|
+
|
|
198
|
+
if not file_path.exists():
|
|
199
|
+
return findings
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
content = file_path.read_text()
|
|
203
|
+
except Exception:
|
|
204
|
+
return findings
|
|
205
|
+
|
|
206
|
+
# Determine file type
|
|
207
|
+
suffix = file_path.suffix.lower()
|
|
208
|
+
|
|
209
|
+
if suffix in ('.yaml', '.yml'):
|
|
210
|
+
findings.extend(self._scan_yaml(file_path, content))
|
|
211
|
+
elif suffix == '.json':
|
|
212
|
+
findings.extend(self._scan_json(file_path, content))
|
|
213
|
+
else:
|
|
214
|
+
# Generic text scan
|
|
215
|
+
findings.extend(self._scan_text(file_path, content))
|
|
216
|
+
|
|
217
|
+
return findings
|
|
218
|
+
|
|
219
|
+
def _scan_yaml(self, file_path: Path, content: str) -> List[SecretFinding]:
|
|
220
|
+
"""Scan YAML content for secrets."""
|
|
221
|
+
findings = []
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
data = yaml.safe_load(content)
|
|
225
|
+
if data:
|
|
226
|
+
findings.extend(self._scan_dict(file_path, data, []))
|
|
227
|
+
except yaml.YAMLError:
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
# Also do text-based scanning for value patterns
|
|
231
|
+
findings.extend(self._scan_value_patterns(file_path, content))
|
|
232
|
+
|
|
233
|
+
return findings
|
|
234
|
+
|
|
235
|
+
def _scan_json(self, file_path: Path, content: str) -> List[SecretFinding]:
|
|
236
|
+
"""Scan JSON content for secrets."""
|
|
237
|
+
findings = []
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
data = json.loads(content)
|
|
241
|
+
if isinstance(data, dict):
|
|
242
|
+
findings.extend(self._scan_dict(file_path, data, []))
|
|
243
|
+
except json.JSONDecodeError:
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
# Also do text-based scanning for value patterns
|
|
247
|
+
findings.extend(self._scan_value_patterns(file_path, content))
|
|
248
|
+
|
|
249
|
+
return findings
|
|
250
|
+
|
|
251
|
+
def _scan_text(self, file_path: Path, content: str) -> List[SecretFinding]:
|
|
252
|
+
"""Scan plain text content for secrets."""
|
|
253
|
+
return self._scan_value_patterns(file_path, content)
|
|
254
|
+
|
|
255
|
+
def _scan_dict(
|
|
256
|
+
self,
|
|
257
|
+
file_path: Path,
|
|
258
|
+
data: Dict[str, Any],
|
|
259
|
+
path: List[str]
|
|
260
|
+
) -> List[SecretFinding]:
|
|
261
|
+
"""Recursively scan a dictionary for secret keys."""
|
|
262
|
+
findings = []
|
|
263
|
+
|
|
264
|
+
for key, value in data.items():
|
|
265
|
+
current_path = path + [key]
|
|
266
|
+
key_path = ".".join(current_path)
|
|
267
|
+
|
|
268
|
+
# Check if key matches secret patterns
|
|
269
|
+
for pattern, secret_type in self.SECRET_KEY_PATTERNS:
|
|
270
|
+
if re.search(pattern, key):
|
|
271
|
+
# Check if value looks like an actual secret (not env var ref)
|
|
272
|
+
if self._is_hardcoded_secret(value):
|
|
273
|
+
findings.append(SecretFinding(
|
|
274
|
+
file_path=file_path,
|
|
275
|
+
secret_type=secret_type,
|
|
276
|
+
key_name=key_path,
|
|
277
|
+
context=self._redact_value(value),
|
|
278
|
+
severity="high",
|
|
279
|
+
))
|
|
280
|
+
break
|
|
281
|
+
|
|
282
|
+
# Recurse into nested dicts
|
|
283
|
+
if isinstance(value, dict):
|
|
284
|
+
findings.extend(self._scan_dict(file_path, value, current_path))
|
|
285
|
+
elif isinstance(value, list):
|
|
286
|
+
for i, item in enumerate(value):
|
|
287
|
+
if isinstance(item, dict):
|
|
288
|
+
findings.extend(
|
|
289
|
+
self._scan_dict(file_path, item, current_path + [f"[{i}]"])
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
return findings
|
|
293
|
+
|
|
294
|
+
def _scan_value_patterns(self, file_path: Path, content: str) -> List[SecretFinding]:
|
|
295
|
+
"""Scan content for secret value patterns."""
|
|
296
|
+
findings = []
|
|
297
|
+
lines = content.split('\n')
|
|
298
|
+
|
|
299
|
+
for pattern, secret_type, severity in self.SECRET_VALUE_PATTERNS:
|
|
300
|
+
for line_num, line in enumerate(lines, 1):
|
|
301
|
+
if re.search(pattern, line):
|
|
302
|
+
# Check it's not an env var reference
|
|
303
|
+
if not self._is_env_var_reference(line):
|
|
304
|
+
findings.append(SecretFinding(
|
|
305
|
+
file_path=file_path,
|
|
306
|
+
secret_type=secret_type,
|
|
307
|
+
key_name=f"line_{line_num}",
|
|
308
|
+
line_number=line_num,
|
|
309
|
+
context=self._redact_line(line),
|
|
310
|
+
severity=severity,
|
|
311
|
+
))
|
|
312
|
+
|
|
313
|
+
return findings
|
|
314
|
+
|
|
315
|
+
def _is_hardcoded_secret(self, value: Any) -> bool:
|
|
316
|
+
"""Check if a value appears to be a hardcoded secret."""
|
|
317
|
+
if value is None:
|
|
318
|
+
return False
|
|
319
|
+
|
|
320
|
+
if not isinstance(value, str):
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
# Empty or very short values are not secrets
|
|
324
|
+
if len(value) < 8:
|
|
325
|
+
return False
|
|
326
|
+
|
|
327
|
+
# Check if it's an environment variable reference
|
|
328
|
+
if self._is_env_var_reference(value):
|
|
329
|
+
return False
|
|
330
|
+
|
|
331
|
+
# Check for obvious placeholder values
|
|
332
|
+
placeholders = [
|
|
333
|
+
'your_', 'xxx', 'changeme', 'placeholder', 'example',
|
|
334
|
+
'<', '>', 'TODO', 'FIXME', 'INSERT', 'REPLACE'
|
|
335
|
+
]
|
|
336
|
+
value_lower = value.lower()
|
|
337
|
+
if any(p in value_lower for p in placeholders):
|
|
338
|
+
return False
|
|
339
|
+
|
|
340
|
+
return True
|
|
341
|
+
|
|
342
|
+
def _is_env_var_reference(self, value: str) -> bool:
|
|
343
|
+
"""Check if value is an environment variable reference."""
|
|
344
|
+
for pattern in self.ENV_VAR_PATTERNS:
|
|
345
|
+
if re.search(pattern, value):
|
|
346
|
+
return True
|
|
347
|
+
return False
|
|
348
|
+
|
|
349
|
+
def _redact_value(self, value: Any) -> str:
|
|
350
|
+
"""Redact a secret value for safe display."""
|
|
351
|
+
if not isinstance(value, str):
|
|
352
|
+
return "<redacted>"
|
|
353
|
+
|
|
354
|
+
if len(value) <= 8:
|
|
355
|
+
return "*" * len(value)
|
|
356
|
+
|
|
357
|
+
# Show first 4 and last 4 chars
|
|
358
|
+
return f"{value[:4]}{'*' * (len(value) - 8)}{value[-4:]}"
|
|
359
|
+
|
|
360
|
+
def _redact_line(self, line: str) -> str:
|
|
361
|
+
"""Redact sensitive parts of a line."""
|
|
362
|
+
# Redact anything that looks like a secret value
|
|
363
|
+
redacted = re.sub(
|
|
364
|
+
r'([\'"])[A-Za-z0-9+/=_-]{16,}([\'"])',
|
|
365
|
+
r'\1***REDACTED***\2',
|
|
366
|
+
line
|
|
367
|
+
)
|
|
368
|
+
return redacted[:100] + "..." if len(redacted) > 100 else redacted
|
|
369
|
+
|
|
370
|
+
def fix_permissions(self, file_path: Path) -> bool:
|
|
371
|
+
"""
|
|
372
|
+
Fix permissions on a sensitive file to 600 (owner read/write only).
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
file_path: Path to the file
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
True if permissions were changed
|
|
379
|
+
"""
|
|
380
|
+
if not file_path.exists():
|
|
381
|
+
return False
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
current_mode = file_path.stat().st_mode
|
|
385
|
+
desired_mode = stat.S_IRUSR | stat.S_IWUSR # 600
|
|
386
|
+
|
|
387
|
+
if (current_mode & 0o777) != desired_mode:
|
|
388
|
+
file_path.chmod(desired_mode)
|
|
389
|
+
return True
|
|
390
|
+
except OSError:
|
|
391
|
+
pass
|
|
392
|
+
|
|
393
|
+
return False
|
|
394
|
+
|
|
395
|
+
def scan_directory(
|
|
396
|
+
self,
|
|
397
|
+
directory: Path,
|
|
398
|
+
patterns: Optional[List[str]] = None
|
|
399
|
+
) -> ScanResult:
|
|
400
|
+
"""
|
|
401
|
+
Scan a directory for configuration files with secrets.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
directory: Directory to scan
|
|
405
|
+
patterns: Glob patterns for files to scan (default: *.yaml, *.yml, *.json)
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
ScanResult with all findings
|
|
409
|
+
"""
|
|
410
|
+
result = ScanResult()
|
|
411
|
+
|
|
412
|
+
if patterns is None:
|
|
413
|
+
patterns = ["**/*.yaml", "**/*.yml", "**/*.json", "**/.env*"]
|
|
414
|
+
|
|
415
|
+
files_to_scan = set()
|
|
416
|
+
for pattern in patterns:
|
|
417
|
+
files_to_scan.update(directory.glob(pattern))
|
|
418
|
+
|
|
419
|
+
for file_path in files_to_scan:
|
|
420
|
+
if file_path.is_file():
|
|
421
|
+
result.files_scanned += 1
|
|
422
|
+
|
|
423
|
+
findings = self.scan_file(file_path)
|
|
424
|
+
if findings:
|
|
425
|
+
result.files_with_secrets += 1
|
|
426
|
+
result.findings.extend(findings)
|
|
427
|
+
|
|
428
|
+
# Check and fix permissions on sensitive files
|
|
429
|
+
if self.enforce_permissions:
|
|
430
|
+
if file_path.name in self.SENSITIVE_FILES:
|
|
431
|
+
if self.fix_permissions(file_path):
|
|
432
|
+
result.permissions_fixed.append(file_path)
|
|
433
|
+
|
|
434
|
+
return result
|
|
435
|
+
|
|
436
|
+
def scan_tweek_config(self) -> ScanResult:
|
|
437
|
+
"""
|
|
438
|
+
Scan Tweek's configuration directory for secrets.
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
ScanResult with all findings
|
|
442
|
+
"""
|
|
443
|
+
tweek_dir = Path.home() / ".tweek"
|
|
444
|
+
|
|
445
|
+
if not tweek_dir.exists():
|
|
446
|
+
return ScanResult()
|
|
447
|
+
|
|
448
|
+
return self.scan_directory(tweek_dir)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def scan_for_secrets(path: Optional[Path] = None) -> ScanResult:
|
|
452
|
+
"""
|
|
453
|
+
Convenience function to scan for secrets.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
path: Path to scan (default: ~/.tweek)
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
ScanResult with findings
|
|
460
|
+
"""
|
|
461
|
+
scanner = SecretScanner()
|
|
462
|
+
|
|
463
|
+
if path is None:
|
|
464
|
+
return scanner.scan_tweek_config()
|
|
465
|
+
elif path.is_file():
|
|
466
|
+
findings = scanner.scan_file(path)
|
|
467
|
+
return ScanResult(
|
|
468
|
+
findings=findings,
|
|
469
|
+
files_scanned=1,
|
|
470
|
+
files_with_secrets=1 if findings else 0
|
|
471
|
+
)
|
|
472
|
+
else:
|
|
473
|
+
return scanner.scan_directory(path)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def enforce_env_only_secrets(
|
|
477
|
+
config_path: Optional[Path] = None,
|
|
478
|
+
raise_on_secrets: bool = True
|
|
479
|
+
) -> Tuple[bool, ScanResult]:
|
|
480
|
+
"""
|
|
481
|
+
Enforce that secrets come from environment variables only.
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
config_path: Path to scan (default: ~/.tweek)
|
|
485
|
+
raise_on_secrets: If True, raise exception when secrets found
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
(is_clean, result) tuple
|
|
489
|
+
|
|
490
|
+
Raises:
|
|
491
|
+
ValueError: If secrets are found and raise_on_secrets is True
|
|
492
|
+
"""
|
|
493
|
+
result = scan_for_secrets(config_path)
|
|
494
|
+
|
|
495
|
+
if not result.is_clean and raise_on_secrets:
|
|
496
|
+
secret_list = "\n".join(
|
|
497
|
+
f" - {f.file_path}:{f.line_number or '?'} ({f.secret_type.value})"
|
|
498
|
+
for f in result.findings[:10]
|
|
499
|
+
)
|
|
500
|
+
raise ValueError(
|
|
501
|
+
f"Hardcoded secrets detected in configuration files:\n{secret_list}\n\n"
|
|
502
|
+
f"Secrets must come from environment variables. "
|
|
503
|
+
f"See recommendations in scan results."
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
return result.is_clean, result
|