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.
Files changed (85) hide show
  1. tweek/__init__.py +16 -0
  2. tweek/cli.py +3390 -0
  3. tweek/cli_helpers.py +193 -0
  4. tweek/config/__init__.py +13 -0
  5. tweek/config/allowed_dirs.yaml +23 -0
  6. tweek/config/manager.py +1064 -0
  7. tweek/config/patterns.yaml +751 -0
  8. tweek/config/tiers.yaml +129 -0
  9. tweek/diagnostics.py +589 -0
  10. tweek/hooks/__init__.py +1 -0
  11. tweek/hooks/pre_tool_use.py +861 -0
  12. tweek/integrations/__init__.py +3 -0
  13. tweek/integrations/moltbot.py +243 -0
  14. tweek/licensing.py +398 -0
  15. tweek/logging/__init__.py +9 -0
  16. tweek/logging/bundle.py +350 -0
  17. tweek/logging/json_logger.py +150 -0
  18. tweek/logging/security_log.py +745 -0
  19. tweek/mcp/__init__.py +24 -0
  20. tweek/mcp/approval.py +456 -0
  21. tweek/mcp/approval_cli.py +356 -0
  22. tweek/mcp/clients/__init__.py +37 -0
  23. tweek/mcp/clients/chatgpt.py +112 -0
  24. tweek/mcp/clients/claude_desktop.py +203 -0
  25. tweek/mcp/clients/gemini.py +178 -0
  26. tweek/mcp/proxy.py +667 -0
  27. tweek/mcp/screening.py +175 -0
  28. tweek/mcp/server.py +317 -0
  29. tweek/platform/__init__.py +131 -0
  30. tweek/plugins/__init__.py +835 -0
  31. tweek/plugins/base.py +1080 -0
  32. tweek/plugins/compliance/__init__.py +30 -0
  33. tweek/plugins/compliance/gdpr.py +333 -0
  34. tweek/plugins/compliance/gov.py +324 -0
  35. tweek/plugins/compliance/hipaa.py +285 -0
  36. tweek/plugins/compliance/legal.py +322 -0
  37. tweek/plugins/compliance/pci.py +361 -0
  38. tweek/plugins/compliance/soc2.py +275 -0
  39. tweek/plugins/detectors/__init__.py +30 -0
  40. tweek/plugins/detectors/continue_dev.py +206 -0
  41. tweek/plugins/detectors/copilot.py +254 -0
  42. tweek/plugins/detectors/cursor.py +192 -0
  43. tweek/plugins/detectors/moltbot.py +205 -0
  44. tweek/plugins/detectors/windsurf.py +214 -0
  45. tweek/plugins/git_discovery.py +395 -0
  46. tweek/plugins/git_installer.py +491 -0
  47. tweek/plugins/git_lockfile.py +338 -0
  48. tweek/plugins/git_registry.py +503 -0
  49. tweek/plugins/git_security.py +482 -0
  50. tweek/plugins/providers/__init__.py +30 -0
  51. tweek/plugins/providers/anthropic.py +181 -0
  52. tweek/plugins/providers/azure_openai.py +289 -0
  53. tweek/plugins/providers/bedrock.py +248 -0
  54. tweek/plugins/providers/google.py +197 -0
  55. tweek/plugins/providers/openai.py +230 -0
  56. tweek/plugins/scope.py +130 -0
  57. tweek/plugins/screening/__init__.py +26 -0
  58. tweek/plugins/screening/llm_reviewer.py +149 -0
  59. tweek/plugins/screening/pattern_matcher.py +273 -0
  60. tweek/plugins/screening/rate_limiter.py +174 -0
  61. tweek/plugins/screening/session_analyzer.py +159 -0
  62. tweek/proxy/__init__.py +302 -0
  63. tweek/proxy/addon.py +223 -0
  64. tweek/proxy/interceptor.py +313 -0
  65. tweek/proxy/server.py +315 -0
  66. tweek/sandbox/__init__.py +71 -0
  67. tweek/sandbox/executor.py +382 -0
  68. tweek/sandbox/linux.py +278 -0
  69. tweek/sandbox/profile_generator.py +323 -0
  70. tweek/screening/__init__.py +13 -0
  71. tweek/screening/context.py +81 -0
  72. tweek/security/__init__.py +22 -0
  73. tweek/security/llm_reviewer.py +348 -0
  74. tweek/security/rate_limiter.py +682 -0
  75. tweek/security/secret_scanner.py +506 -0
  76. tweek/security/session_analyzer.py +600 -0
  77. tweek/vault/__init__.py +40 -0
  78. tweek/vault/cross_platform.py +251 -0
  79. tweek/vault/keychain.py +288 -0
  80. tweek-0.1.0.dist-info/METADATA +335 -0
  81. tweek-0.1.0.dist-info/RECORD +85 -0
  82. tweek-0.1.0.dist-info/WHEEL +5 -0
  83. tweek-0.1.0.dist-info/entry_points.txt +25 -0
  84. tweek-0.1.0.dist-info/licenses/LICENSE +190 -0
  85. 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