@paulduvall/claude-dev-toolkit 0.0.1-alpha.2 → 0.0.1-alpha.21

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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -37
  3. package/bin/claude-commands +307 -65
  4. package/commands/active/xarchitecture.md +393 -0
  5. package/commands/active/xconfig.md +127 -0
  6. package/commands/active/xcontinue.md +92 -0
  7. package/commands/active/xdebug.md +130 -0
  8. package/commands/active/xdocs.md +178 -0
  9. package/commands/active/xexplore.md +94 -0
  10. package/commands/active/xgit.md +149 -0
  11. package/commands/active/xpipeline.md +152 -0
  12. package/commands/active/xquality.md +96 -0
  13. package/commands/active/xrefactor.md +198 -0
  14. package/commands/active/xrelease.md +142 -0
  15. package/commands/active/xsecurity.md +92 -0
  16. package/commands/active/xspec.md +174 -0
  17. package/commands/active/xtdd.md +151 -0
  18. package/commands/active/xtest.md +89 -0
  19. package/commands/active/xverify.md +80 -0
  20. package/commands/experiments/xact.md +742 -0
  21. package/commands/experiments/xanalytics.md +113 -0
  22. package/commands/experiments/xanalyze.md +70 -0
  23. package/commands/experiments/xapi.md +161 -0
  24. package/commands/experiments/xatomic.md +112 -0
  25. package/commands/experiments/xaws.md +85 -0
  26. package/commands/experiments/xcicd.md +337 -0
  27. package/commands/experiments/xcommit.md +122 -0
  28. package/commands/experiments/xcompliance.md +182 -0
  29. package/commands/experiments/xconstraints.md +89 -0
  30. package/commands/experiments/xcoverage.md +90 -0
  31. package/commands/experiments/xdb.md +102 -0
  32. package/commands/experiments/xdesign.md +121 -0
  33. package/commands/experiments/xdevcontainer.md +238 -0
  34. package/commands/experiments/xevaluate.md +111 -0
  35. package/commands/experiments/xfootnote.md +12 -0
  36. package/commands/experiments/xgenerate.md +117 -0
  37. package/commands/experiments/xgovernance.md +149 -0
  38. package/commands/experiments/xgreen.md +66 -0
  39. package/commands/experiments/xiac.md +118 -0
  40. package/commands/experiments/xincident.md +137 -0
  41. package/commands/experiments/xinfra.md +115 -0
  42. package/commands/experiments/xknowledge.md +115 -0
  43. package/commands/experiments/xmaturity.md +120 -0
  44. package/commands/experiments/xmetrics.md +118 -0
  45. package/commands/experiments/xmonitoring.md +128 -0
  46. package/commands/experiments/xnew.md +903 -0
  47. package/commands/experiments/xobservable.md +114 -0
  48. package/commands/experiments/xoidc.md +165 -0
  49. package/commands/experiments/xoptimize.md +115 -0
  50. package/commands/experiments/xperformance.md +112 -0
  51. package/commands/experiments/xplanning.md +131 -0
  52. package/commands/experiments/xpolicy.md +115 -0
  53. package/commands/experiments/xproduct.md +98 -0
  54. package/commands/experiments/xreadiness.md +75 -0
  55. package/commands/experiments/xred.md +55 -0
  56. package/commands/experiments/xrisk.md +128 -0
  57. package/commands/experiments/xrules.md +124 -0
  58. package/commands/experiments/xsandbox.md +120 -0
  59. package/commands/experiments/xscan.md +102 -0
  60. package/commands/experiments/xsetup.md +123 -0
  61. package/commands/experiments/xtemplate.md +116 -0
  62. package/commands/experiments/xtrace.md +212 -0
  63. package/commands/experiments/xux.md +171 -0
  64. package/commands/experiments/xvalidate.md +104 -0
  65. package/commands/experiments/xworkflow.md +113 -0
  66. package/hooks/.smellrc.example.json +19 -0
  67. package/hooks/README.md +263 -0
  68. package/hooks/check-commit-signing.py +127 -0
  69. package/hooks/check-complexity.py +38 -0
  70. package/hooks/check-security.py +37 -0
  71. package/hooks/claude-wrapper.sh +29 -0
  72. package/hooks/config.py +110 -0
  73. package/hooks/file-logger.sh +100 -0
  74. package/hooks/lib/argument-parser.sh +427 -0
  75. package/hooks/lib/config-constants.sh +230 -0
  76. package/hooks/lib/context-manager.sh +560 -0
  77. package/hooks/lib/error-handler.sh +423 -0
  78. package/hooks/lib/execution-engine.sh +444 -0
  79. package/hooks/lib/execution-results.sh +113 -0
  80. package/hooks/lib/execution-simulation.sh +114 -0
  81. package/hooks/lib/field-validators.sh +104 -0
  82. package/hooks/lib/file-utils.sh +398 -0
  83. package/hooks/lib/subagent-discovery.sh +468 -0
  84. package/hooks/lib/subagent-validator.sh +407 -0
  85. package/hooks/lib/validation-reporter.sh +134 -0
  86. package/hooks/on-error-debug.sh +226 -0
  87. package/hooks/pre-commit-quality.sh +204 -0
  88. package/hooks/pre-commit-test-runner.sh +132 -0
  89. package/hooks/pre-write-security.sh +115 -0
  90. package/hooks/prevent-credential-exposure.sh +279 -0
  91. package/hooks/security_bandit.py +177 -0
  92. package/hooks/security_checks.py +97 -0
  93. package/hooks/security_secrets.py +81 -0
  94. package/hooks/security_trojan.py +61 -0
  95. package/hooks/settings.example.json +52 -0
  96. package/hooks/smell_checks.py +238 -0
  97. package/hooks/smell_javascript.py +231 -0
  98. package/hooks/smell_python.py +110 -0
  99. package/hooks/smell_ruff.py +70 -0
  100. package/hooks/smell_types.py +72 -0
  101. package/hooks/subagent-trigger-simple.sh +202 -0
  102. package/hooks/subagent-trigger.sh +253 -0
  103. package/hooks/suppression.py +82 -0
  104. package/hooks/tab-color.sh +70 -0
  105. package/hooks/verify-before-edit.sh +135 -0
  106. package/lib/backup-restore-command.js +140 -0
  107. package/lib/base/base-command.js +252 -0
  108. package/lib/base/command-result.js +184 -0
  109. package/lib/config/constants.js +255 -0
  110. package/lib/config.js +48 -6
  111. package/lib/configure-command.js +428 -0
  112. package/lib/dependency-validator.js +64 -5
  113. package/lib/hook-installer-core.js +2 -2
  114. package/lib/installation-instruction-generator.js +213 -495
  115. package/lib/installer.js +134 -56
  116. package/lib/oidc-command.js +740 -0
  117. package/lib/services/backup-list-service.js +226 -0
  118. package/lib/services/backup-service.js +230 -0
  119. package/lib/services/command-installer-service.js +217 -0
  120. package/lib/services/logger-service.js +201 -0
  121. package/lib/services/package-manager-service.js +319 -0
  122. package/lib/services/platform-instruction-service.js +294 -0
  123. package/lib/services/recovery-instruction-service.js +348 -0
  124. package/lib/services/restore-service.js +221 -0
  125. package/lib/setup-command.js +359 -0
  126. package/lib/setup-wizard.js +155 -262
  127. package/lib/uninstall-command.js +100 -0
  128. package/lib/utils/claude-path-config.js +184 -0
  129. package/lib/utils/file-system-utils.js +152 -0
  130. package/lib/utils.js +8 -4
  131. package/lib/verify-command.js +430 -0
  132. package/package.json +7 -3
  133. package/scripts/postinstall.js +172 -157
  134. package/subagents/debug-specialist.md +7 -0
  135. package/templates/README.md +115 -0
  136. package/templates/basic-settings.json +30 -0
  137. package/templates/comprehensive-settings.json +57 -0
  138. package/templates/global-claude.md +344 -0
  139. package/templates/hybrid-hook-config.yaml +132 -0
  140. package/templates/security-focused-settings.json +62 -0
  141. package/templates/subagent-hooks.yaml +188 -0
  142. package/lib/package-manager-service.js +0 -270
  143. package/subagents/debug-context.md +0 -197
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Claude Code Hook: Prevent Credential Exposure
5
+ #
6
+ # Purpose: Scan for exposed credentials before any file write/edit operations
7
+ # Trigger: PreToolUse for Edit, Write, MultiEdit tools
8
+ # Blocking: Yes - prevents credential exposure
9
+ #
10
+ # This hook implements enterprise-grade security by detecting and preventing
11
+ # accidental credential exposure in AI-generated or AI-modified code.
12
+
13
+ ##################################
14
+ # Configuration
15
+ ##################################
16
+ HOOK_NAME="prevent-credential-exposure"
17
+ LOG_FILE="$HOME/.claude/logs/security-hooks.log"
18
+ VIOLATION_LOG="$HOME/.claude/logs/credential-violations.log"
19
+ NOTIFICATION_WEBHOOK="${SECURITY_WEBHOOK_URL:-}"
20
+
21
+ # Ensure log directory exists with secure permissions
22
+ mkdir -p "$(dirname "$LOG_FILE")"
23
+ chmod 700 "$(dirname "$LOG_FILE")"
24
+
25
+ # Create log files with restrictive permissions if they don't exist
26
+ touch "$LOG_FILE" "$VIOLATION_LOG"
27
+ chmod 600 "$LOG_FILE" "$VIOLATION_LOG"
28
+
29
+ ##################################
30
+ # Logging Functions
31
+ ##################################
32
+ log() {
33
+ echo "[$(date +'%Y-%m-%d %H:%M:%S')] [$HOOK_NAME] $*" | tee -a "$LOG_FILE"
34
+ }
35
+
36
+ log_violation() {
37
+ echo "[$(date +'%Y-%m-%d %H:%M:%S')] VIOLATION: $*" | tee -a "$VIOLATION_LOG"
38
+ }
39
+
40
+ ##################################
41
+ # JSON Utilities
42
+ ##################################
43
+ json_escape() {
44
+ local input="$1"
45
+ input="${input//\\/\\\\}"
46
+ input="${input//\"/\\\"}"
47
+ input="${input//$'\n'/\\n}"
48
+ input="${input//$'\r'/\\r}"
49
+ input="${input//$'\t'/\\t}"
50
+ printf '%s' "$input"
51
+ }
52
+
53
+ ##################################
54
+ # Notification Functions
55
+ ##################################
56
+ notify_security_team() {
57
+ local violation_type="$1"
58
+ local file_path="$2"
59
+ local pattern="$3"
60
+
61
+ if [[ -n "$NOTIFICATION_WEBHOOK" ]]; then
62
+ local safe_type safe_path safe_pattern safe_user safe_ts
63
+ safe_type=$(json_escape "$violation_type")
64
+ safe_path=$(json_escape "$file_path")
65
+ safe_pattern=$(json_escape "$pattern")
66
+ safe_user=$(json_escape "$USER")
67
+ safe_ts=$(json_escape "$(date)")
68
+
69
+ curl -s -X POST "$NOTIFICATION_WEBHOOK" \
70
+ -H "Content-Type: application/json" \
71
+ -d "{
72
+ \"text\": \"SECURITY ALERT: Credential exposure prevented\",
73
+ \"attachments\": [{
74
+ \"color\": \"danger\",
75
+ \"fields\": [
76
+ {\"title\": \"Violation Type\", \"value\": \"$safe_type\", \"short\": true},
77
+ {\"title\": \"File\", \"value\": \"$safe_path\", \"short\": true},
78
+ {\"title\": \"Pattern\", \"value\": \"$safe_pattern\", \"short\": false},
79
+ {\"title\": \"User\", \"value\": \"$safe_user\", \"short\": true},
80
+ {\"title\": \"Timestamp\", \"value\": \"$safe_ts\", \"short\": true}
81
+ ]
82
+ }]
83
+ }" 2>/dev/null || log "Failed to send security notification"
84
+ fi
85
+ }
86
+
87
+ ##################################
88
+ # Credential Detection Patterns
89
+ ##################################
90
+ # High-confidence patterns for common credential types
91
+ declare -A CREDENTIAL_PATTERNS
92
+
93
+ # API Keys
94
+ CREDENTIAL_PATTERNS["anthropic_api_key"]='sk-ant-[a-zA-Z0-9]{95}'
95
+ CREDENTIAL_PATTERNS["openai_api_key"]='sk-[a-zA-Z0-9]{32,}'
96
+ CREDENTIAL_PATTERNS["github_token"]='gh[po]_[a-zA-Z0-9]{36}'
97
+ CREDENTIAL_PATTERNS["aws_access_key"]='AKIA[0-9A-Z]{16}'
98
+ CREDENTIAL_PATTERNS["azure_key"]='[a-zA-Z0-9/+]{86}=='
99
+
100
+ # Database URLs with credentials
101
+ CREDENTIAL_PATTERNS["database_url_with_password"]='(mysql|postgresql|mongodb)://[^:]+:[^@]+@'
102
+
103
+ # Generic high-entropy patterns
104
+ CREDENTIAL_PATTERNS["generic_api_key"]='["'"'"']?[a-zA-Z0-9_-]*[aA][pP][iI][_-]?[kK][eE][yY]["'"'"']?\s*[:=]\s*["'"'"'][a-zA-Z0-9+/=]{20,}["'"'"']'
105
+ CREDENTIAL_PATTERNS["generic_secret"]='["'"'"']?[a-zA-Z0-9_-]*[sS][eE][cC][rR][eE][tT]["'"'"']?\s*[:=]\s*["'"'"'][a-zA-Z0-9+/=]{20,}["'"'"']'
106
+ CREDENTIAL_PATTERNS["generic_password"]='["'"'"']?[a-zA-Z0-9_-]*[pP][aA][sS][sS][wW][oO][rR][dD]["'"'"']?\s*[:=]\s*["'"'"'][^"'"'"']{8,}["'"'"']'
107
+
108
+ # JWT Tokens
109
+ CREDENTIAL_PATTERNS["jwt_token"]='eyJ[a-zA-Z0-9+/=]{20,}\.[a-zA-Z0-9+/=]{20,}\.[a-zA-Z0-9+/=_-]{20,}'
110
+
111
+ # Private Keys
112
+ CREDENTIAL_PATTERNS["private_key"]='-----BEGIN [A-Z ]*PRIVATE KEY-----'
113
+ CREDENTIAL_PATTERNS["ssh_private_key"]='-----BEGIN OPENSSH PRIVATE KEY-----'
114
+
115
+ # Cloud Provider Specific
116
+ CREDENTIAL_PATTERNS["gcp_service_account_key"]='"type":\s*"service_account"'
117
+ CREDENTIAL_PATTERNS["slack_webhook"]='hooks\.slack\.com/services/[A-Z0-9]{9}/[A-Z0-9]{11}/[a-zA-Z0-9]{24}'
118
+
119
+ ##################################
120
+ # Content Analysis Functions
121
+ ##################################
122
+ scan_file_content() {
123
+ local file_path="$1"
124
+ local content="$2"
125
+ local violations=()
126
+
127
+ # Skip if file doesn't exist or is binary
128
+ if [[ ! -f "$file_path" ]]; then
129
+ return 0
130
+ fi
131
+
132
+ # Check if file is binary (avoid scanning binary files)
133
+ if file "$file_path" 2>/dev/null | grep -q "binary"; then
134
+ return 0
135
+ fi
136
+
137
+ # Redirect log output to stderr so it doesn't pollute stdout
138
+ # (callers capture stdout via command substitution for the violation count)
139
+ log "Scanning file: $file_path" >&2
140
+
141
+ # Check each credential pattern
142
+ for pattern_name in "${!CREDENTIAL_PATTERNS[@]}"; do
143
+ local pattern="${CREDENTIAL_PATTERNS[$pattern_name]}"
144
+
145
+ if echo "$content" | grep -qiP -e "$pattern"; then
146
+ log_violation "$pattern_name detected in $file_path" >&2
147
+ violations+=("$pattern_name")
148
+
149
+ # Extract the matched content for logging (but redact it)
150
+ local matched_line
151
+ matched_line=$(echo "$content" | grep -iP -e "$pattern" | head -1)
152
+ local redacted_line
153
+ redacted_line=$(echo "$matched_line" | sed 's/[a-zA-Z0-9+/=]\{10,\}/[REDACTED]/g')
154
+
155
+ log_violation "Pattern: $pattern_name, Line: $redacted_line" >&2
156
+
157
+ # Notify security team
158
+ notify_security_team "$pattern_name" "$file_path" "$redacted_line"
159
+ fi
160
+ done
161
+
162
+ # Return violation count
163
+ echo "${#violations[@]}"
164
+ }
165
+
166
+ check_environment_leakage() {
167
+ local content="$1"
168
+ local violations=0
169
+
170
+ # Check for environment variable exposure patterns
171
+ if echo "$content" | grep -qiP 'process\.env\.[A-Z_]*(?:KEY|SECRET|PASSWORD|TOKEN)'; then
172
+ log_violation "Environment variable credential exposure detected" >&2
173
+ ((violations++))
174
+ fi
175
+
176
+ # Check for hardcoded production URLs with credentials
177
+ if echo "$content" | grep -qiP 'https?://[^:]+:[^@]+@[^/]+'; then
178
+ log_violation "URL with embedded credentials detected" >&2
179
+ ((violations++))
180
+ fi
181
+
182
+ echo "$violations"
183
+ }
184
+
185
+ ##################################
186
+ # Dependency Validation
187
+ ##################################
188
+ validate_hook_dependencies() {
189
+ local deps=("grep" "file" "sed" "head")
190
+ local missing=()
191
+
192
+ for dep in "${deps[@]}"; do
193
+ if ! command -v "$dep" &> /dev/null; then
194
+ missing+=("$dep")
195
+ fi
196
+ done
197
+
198
+ if [[ ${#missing[@]} -gt 0 ]]; then
199
+ log "ERROR: Missing required dependencies: ${missing[*]}"
200
+ echo "Install missing tools and retry"
201
+ exit 1
202
+ fi
203
+ }
204
+
205
+ ##################################
206
+ # Main Hook Logic
207
+ ##################################
208
+ main() {
209
+ # Validate dependencies first
210
+ validate_hook_dependencies
211
+ local tool_name="${CLAUDE_TOOL:-unknown}"
212
+ local file_path="${CLAUDE_FILE:-}"
213
+ local content=""
214
+
215
+ log "Hook triggered for tool: $tool_name"
216
+
217
+ # Only process file modification tools
218
+ case "$tool_name" in
219
+ "Edit"|"Write"|"MultiEdit")
220
+ ;;
221
+ *)
222
+ log "Skipping non-file tool: $tool_name"
223
+ exit 0
224
+ ;;
225
+ esac
226
+
227
+ # Get file content to analyze
228
+ if [[ -n "$file_path" ]] && [[ -f "$file_path" ]]; then
229
+ content=$(cat "$file_path" 2>/dev/null || echo "")
230
+ elif [[ -n "$CLAUDE_CONTENT" ]]; then
231
+ content="$CLAUDE_CONTENT"
232
+ file_path="${file_path:-stdin}"
233
+ else
234
+ log "No content to analyze"
235
+ exit 0
236
+ fi
237
+
238
+ # Perform security scans
239
+ local credential_violations
240
+ local env_violations
241
+
242
+ credential_violations=$(scan_file_content "$file_path" "$content")
243
+ env_violations=$(check_environment_leakage "$content")
244
+
245
+ local total_violations=$((credential_violations + env_violations))
246
+
247
+ # Block if violations found
248
+ if [[ $total_violations -gt 0 ]]; then
249
+ echo "🚨 SECURITY VIOLATION: Credential exposure detected!"
250
+ echo "File: $file_path"
251
+ echo "Violations: $total_violations"
252
+ echo ""
253
+ echo "The operation has been BLOCKED to prevent credential exposure."
254
+ echo "Please review the file and remove any exposed credentials before proceeding."
255
+ echo ""
256
+ echo "Common fixes:"
257
+ echo "- Move credentials to environment variables"
258
+ echo "- Use a secrets management system"
259
+ echo "- Add files to .gitignore if they contain test data"
260
+ echo "- Use placeholder values in examples"
261
+ echo ""
262
+ log_violation "BLOCKED: $total_violations violations in $file_path"
263
+
264
+ exit 1
265
+ fi
266
+
267
+ log "Security scan passed for $file_path"
268
+ exit 0
269
+ }
270
+
271
+ ##################################
272
+ # Error Handling
273
+ ##################################
274
+ trap 'log "Hook failed with error on line $LINENO"' ERR
275
+
276
+ ##################################
277
+ # Execute Main Function
278
+ ##################################
279
+ main "$@"
@@ -0,0 +1,177 @@
1
+ """Python AST-based security checks inspired by Bandit.
2
+
3
+ Detects common security anti-patterns without external dependencies:
4
+ B101 (assert), B102 (exec/eval), B105/B106 (hardcoded passwords),
5
+ B110 (try-except-pass), B301 (pickle), B602 (subprocess shell=True).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import os
12
+
13
+ from smell_types import FIXES, Smell
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Individual check functions
17
+ # ---------------------------------------------------------------------------
18
+
19
+ _PASSWORD_NAMES = frozenset((
20
+ "password", "passwd", "pwd", "secret", "token", "api_key",
21
+ ))
22
+
23
+
24
+ def _is_test_file(file_path: str) -> bool:
25
+ """Return True if file looks like a test file."""
26
+ base = os.path.basename(file_path)
27
+ return base.startswith("test_") or base.endswith("_test.py")
28
+
29
+
30
+ def _check_assert(node: ast.Assert, file_path: str) -> Smell | None:
31
+ """B101: assert used outside test files."""
32
+ if _is_test_file(file_path):
33
+ return None
34
+ return Smell(
35
+ "B101", "assert", node.lineno,
36
+ "assert used in non-test code",
37
+ FIXES["B101"],
38
+ )
39
+
40
+
41
+ def _check_exec_eval(node: ast.Call) -> Smell | None:
42
+ """B102: exec() or eval() call detected."""
43
+ func = node.func
44
+ if isinstance(func, ast.Name) and func.id in ("exec", "eval"):
45
+ return Smell(
46
+ "B102", func.id, node.lineno,
47
+ f"{func.id}() call detected",
48
+ FIXES["B102"],
49
+ )
50
+ return None
51
+
52
+
53
+ def _check_hardcoded_password(node: ast.Assign) -> Smell | None:
54
+ """B105/B106: hardcoded string assigned to password-like variable."""
55
+ if not isinstance(node.value, ast.Constant):
56
+ return None
57
+ if not isinstance(node.value.value, str):
58
+ return None
59
+ if not node.value.value or len(node.value.value) < 2:
60
+ return None
61
+ for target in node.targets:
62
+ name = _extract_name(target)
63
+ if name and name.lower() in _PASSWORD_NAMES:
64
+ return Smell(
65
+ "B105", name, node.lineno,
66
+ f"hardcoded value assigned to '{name}'",
67
+ FIXES["B105"],
68
+ )
69
+ return None
70
+
71
+
72
+ def _extract_name(node: ast.AST) -> str | None:
73
+ """Extract variable name from an assignment target."""
74
+ if isinstance(node, ast.Name):
75
+ return node.id
76
+ if isinstance(node, ast.Attribute):
77
+ return node.attr
78
+ return None
79
+
80
+
81
+ def _check_except_pass(node: ast.ExceptHandler) -> Smell | None:
82
+ """B110: except block that only contains pass."""
83
+ if len(node.body) == 1 and isinstance(node.body[0], ast.Pass):
84
+ return Smell(
85
+ "B110", "except-pass", node.lineno,
86
+ "except block silently passes",
87
+ FIXES["B110"],
88
+ )
89
+ return None
90
+
91
+
92
+ def _check_pickle(node: ast.Call) -> Smell | None:
93
+ """B301: pickle.loads/load call detected."""
94
+ func = node.func
95
+ if not isinstance(func, ast.Attribute):
96
+ return None
97
+ if func.attr not in ("load", "loads"):
98
+ return None
99
+ if isinstance(func.value, ast.Name) and func.value.id == "pickle":
100
+ return Smell(
101
+ "B301", "pickle", node.lineno,
102
+ f"pickle.{func.attr}() used on potentially untrusted data",
103
+ FIXES["B301"],
104
+ )
105
+ return None
106
+
107
+
108
+ def _check_subprocess_shell(node: ast.Call) -> Smell | None:
109
+ """B602: subprocess call with shell=True."""
110
+ func = node.func
111
+ if not isinstance(func, ast.Attribute):
112
+ return None
113
+ if func.attr not in ("call", "run", "Popen", "check_output"):
114
+ return None
115
+ if not (isinstance(func.value, ast.Name) and func.value.id == "subprocess"):
116
+ return None
117
+ for kw in node.keywords:
118
+ if kw.arg == "shell" and _is_true_constant(kw.value):
119
+ return Smell(
120
+ "B602", f"subprocess.{func.attr}", node.lineno,
121
+ "subprocess call with shell=True",
122
+ FIXES["B602"],
123
+ )
124
+ return None
125
+
126
+
127
+ def _is_true_constant(node: ast.AST) -> bool:
128
+ """Return True if the node is the constant True."""
129
+ return isinstance(node, ast.Constant) and node.value is True
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Public API
134
+ # ---------------------------------------------------------------------------
135
+
136
+ def check_bandit(file_path: str, source: str) -> list[Smell]:
137
+ """Run AST-based security checks on Python source.
138
+
139
+ Args:
140
+ file_path: Path for context (test file detection).
141
+ source: Python source code string.
142
+
143
+ Returns:
144
+ List of detected security violations.
145
+ """
146
+ try:
147
+ tree = ast.parse(source, filename=file_path)
148
+ except SyntaxError:
149
+ return []
150
+ smells: list[Smell] = []
151
+ for node in ast.walk(tree):
152
+ smell = _dispatch_check(node, file_path)
153
+ if smell is not None:
154
+ smells.append(smell)
155
+ return smells
156
+
157
+
158
+ def _dispatch_check(node: ast.AST, file_path: str) -> Smell | None:
159
+ """Route an AST node to the appropriate security check."""
160
+ if isinstance(node, ast.Assert):
161
+ return _check_assert(node, file_path)
162
+ if isinstance(node, ast.Call):
163
+ return _check_call(node)
164
+ if isinstance(node, ast.Assign):
165
+ return _check_hardcoded_password(node)
166
+ if isinstance(node, ast.ExceptHandler):
167
+ return _check_except_pass(node)
168
+ return None
169
+
170
+
171
+ def _check_call(node: ast.Call) -> Smell | None:
172
+ """Run all Call-node security checks, return first match."""
173
+ for checker in (_check_exec_eval, _check_pickle, _check_subprocess_shell):
174
+ result = checker(node)
175
+ if result is not None:
176
+ return result
177
+ return None
@@ -0,0 +1,97 @@
1
+ """Security check orchestrator -- runs secrets, bandit, and trojan checks.
2
+
3
+ Reads a source file, runs applicable security checks, and returns
4
+ Smell objects for any violations. Respects per-project config.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+ from config import Config, load_config, is_file_suppressed
12
+ from security_bandit import check_bandit
13
+ from security_secrets import check_secrets
14
+ from security_trojan import check_trojan
15
+ from smell_types import Smell
16
+ from suppression import filter_suppressed
17
+
18
+ PYTHON_EXTS = frozenset((".py",))
19
+ ALL_EXTS = frozenset((
20
+ ".py", ".js", ".jsx", ".ts", ".tsx", ".java", ".go",
21
+ ".rs", ".c", ".cpp", ".cs", ".rb", ".sh", ".yaml", ".yml",
22
+ ".json", ".toml", ".env", ".cfg", ".ini", ".conf",
23
+ ))
24
+ SKIP_DIRS = frozenset((
25
+ "node_modules", "__pycache__", ".git", "dist", "build", ".next",
26
+ ))
27
+
28
+
29
+ def _should_skip(file_path: str) -> bool:
30
+ """Return True for paths in ignored directories."""
31
+ parts = set(file_path.replace("\\", "/").split("/"))
32
+ return bool(SKIP_DIRS & parts)
33
+
34
+
35
+ def _read_source(file_path: str) -> str | None:
36
+ """Read file contents, returning None on failure."""
37
+ try:
38
+ with open(file_path, "r", encoding="utf-8", errors="replace") as fh:
39
+ return fh.read()
40
+ except OSError:
41
+ return None
42
+
43
+
44
+ def _run_checks(file_path: str, source: str, config: Config) -> list[Smell]:
45
+ """Run applicable security checks on file contents."""
46
+ lines = source.splitlines()
47
+ smells = _collect_violations(file_path, source, lines, config)
48
+ return filter_suppressed(smells, lines, "security")
49
+
50
+
51
+ def _collect_violations(
52
+ file_path: str, source: str, lines: list[str], config: Config,
53
+ ) -> list[Smell]:
54
+ """Gather raw violations before suppression filtering."""
55
+ smells: list[Smell] = []
56
+ if config.security_enabled:
57
+ smells.extend(check_secrets(lines))
58
+ ext = os.path.splitext(file_path)[1]
59
+ if ext in PYTHON_EXTS and config.security_enabled:
60
+ smells.extend(check_bandit(file_path, source))
61
+ if config.trojan_enabled:
62
+ smells.extend(check_trojan(lines))
63
+ return smells
64
+
65
+
66
+ def _is_excluded(file_path: str, ext: str, config: Config) -> bool:
67
+ """Return True if file should be excluded from security checks."""
68
+ if ext not in ALL_EXTS or _should_skip(file_path):
69
+ return True
70
+ return is_file_suppressed(file_path, config)
71
+
72
+
73
+ def check_security(file_path: str, config: Config | None = None) -> list[Smell]:
74
+ """Run all security checks on a single file."""
75
+ if config is None:
76
+ config = load_config(file_path)
77
+ ext = os.path.splitext(file_path)[1]
78
+ if _is_excluded(file_path, ext, config):
79
+ return []
80
+ source = _read_source(file_path)
81
+ if source is None:
82
+ return []
83
+ return _run_checks(file_path, source, config)
84
+
85
+
86
+ def format_security_violations(file_path: str, smells: list[Smell]) -> str:
87
+ """Build the feedback message for security violations."""
88
+ header = f"\nSECURITY VIOLATIONS in {file_path}:"
89
+ details = []
90
+ for s in smells:
91
+ tag = s.kind.upper().replace("_", " ")
92
+ details.append(f" [{tag}] {s.name} line {s.line}: {s.detail}")
93
+ fixes = ["\nFix these security issues before moving on:"]
94
+ for fix in dict.fromkeys(s.fix for s in smells):
95
+ fixes.append(f" - {fix}")
96
+ fixes.append("Then notify the user what you fixed and why.")
97
+ return "\n".join([header, *details, *fixes])
@@ -0,0 +1,81 @@
1
+ """Hardcoded secrets detection via regex patterns.
2
+
3
+ Scans source lines for common secret patterns: API keys, tokens,
4
+ private key headers, and credential URLs. Skips known false positives.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from smell_types import FIXES, Smell
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Secret patterns -- each is (compiled_regex, description)
15
+ # ---------------------------------------------------------------------------
16
+
17
+ _PATTERNS: list[tuple[re.Pattern[str], str]] = [
18
+ (re.compile(r"AKIA[0-9A-Z]{16}"), "AWS access key"),
19
+ (re.compile(r"ghp_[0-9a-zA-Z]{36}"), "GitHub personal access token"),
20
+ (re.compile(r"gho_[0-9a-zA-Z]{36}"), "GitHub OAuth token"),
21
+ (re.compile(r"xoxb-[0-9]{10,13}-[0-9a-zA-Z-]+"), "Slack bot token"),
22
+ (re.compile(r"xoxp-[0-9]{10,13}-[0-9a-zA-Z-]+"), "Slack user token"),
23
+ (re.compile(r"sk_live_[0-9a-zA-Z]{24,}"), "Stripe secret key"),
24
+ (re.compile(r"rk_live_[0-9a-zA-Z]{24,}"), "Stripe restricted key"),
25
+ (re.compile(r"AIza[0-9A-Za-z_-]{35}"), "Google API key"),
26
+ (re.compile(r"sk-[0-9a-zA-Z]{20,}T3BlbkFJ[0-9a-zA-Z]+"), "OpenAI API key"),
27
+ (re.compile(r"-----BEGIN\s+(RSA |EC |DSA )?PRIVATE KEY-----"), "private key"),
28
+ (re.compile(r"[a-zA-Z+]+://[^:]+:[^@]+@[^\s]+"), "credentials in URL"),
29
+ ]
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # False-positive skip heuristics
33
+ # ---------------------------------------------------------------------------
34
+
35
+ _FP_WORDS = re.compile(
36
+ r"(example|fake|test|dummy|placeholder|xxxx|TODO|CHANGEME)",
37
+ re.IGNORECASE,
38
+ )
39
+
40
+
41
+ def _is_false_positive(line: str) -> bool:
42
+ """Return True if the line looks like a placeholder or example."""
43
+ stripped = line.strip()
44
+ if stripped.startswith(("#", "//", "/*", "*")):
45
+ return True
46
+ return bool(_FP_WORDS.search(stripped))
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Public API
51
+ # ---------------------------------------------------------------------------
52
+
53
+ def _match_line(line: str, lineno: int) -> Smell | None:
54
+ """Check a single line against all secret patterns."""
55
+ if _is_false_positive(line):
56
+ return None
57
+ for pattern, desc in _PATTERNS:
58
+ if pattern.search(line):
59
+ return Smell(
60
+ "secrets", desc, lineno,
61
+ f"possible {desc} detected",
62
+ FIXES["secrets"],
63
+ )
64
+ return None
65
+
66
+
67
+ def check_secrets(lines: list[str]) -> list[Smell]:
68
+ """Scan lines for hardcoded secret patterns.
69
+
70
+ Args:
71
+ lines: Source file lines to scan.
72
+
73
+ Returns:
74
+ List of Smell objects for detected secrets.
75
+ """
76
+ smells: list[Smell] = []
77
+ for lineno, line in enumerate(lines, start=1):
78
+ smell = _match_line(line, lineno)
79
+ if smell is not None:
80
+ smells.append(smell)
81
+ return smells
@@ -0,0 +1,61 @@
1
+ """Unicode trojan source detection.
2
+
3
+ Detects bidirectional override characters and zero-width characters
4
+ that can be used to disguise malicious code. BOM at file start is allowed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from smell_types import FIXES, Smell
12
+
13
+ # Bidi overrides: U+202A-U+202E
14
+ _BIDI_OVERRIDES = re.compile(r"[\u202A-\u202E]")
15
+ # Bidi isolates: U+2066-U+2069
16
+ _BIDI_ISOLATES = re.compile(r"[\u2066-\u2069]")
17
+ # Zero-width chars: U+200B-U+200F, U+FEFF (BOM)
18
+ _ZERO_WIDTH = re.compile(r"[\u200B-\u200F\uFEFF]")
19
+
20
+
21
+ def _check_bidi(line: str, lineno: int) -> Smell | None:
22
+ """Check a single line for bidi override/isolate characters."""
23
+ if _BIDI_OVERRIDES.search(line) or _BIDI_ISOLATES.search(line):
24
+ return Smell(
25
+ "trojan_bidi", "bidi-override", lineno,
26
+ "Unicode bidi override character detected",
27
+ FIXES["trojan_bidi"],
28
+ )
29
+ return None
30
+
31
+
32
+ def _check_zero_width(line: str, lineno: int) -> Smell | None:
33
+ """Check a single line for zero-width characters."""
34
+ match = _ZERO_WIDTH.search(line)
35
+ if not match:
36
+ return None
37
+ if lineno == 1 and match.start() == 0 and match.group() == "\uFEFF":
38
+ return None
39
+ return Smell(
40
+ "trojan_zero_width", "zero-width-char", lineno,
41
+ "zero-width Unicode character detected",
42
+ FIXES["trojan_zero_width"],
43
+ )
44
+
45
+
46
+ def check_trojan(lines: list[str]) -> list[Smell]:
47
+ """Scan lines for trojan source Unicode characters.
48
+
49
+ Args:
50
+ lines: Source file lines to scan.
51
+
52
+ Returns:
53
+ List of Smell objects for detected trojan characters.
54
+ """
55
+ smells: list[Smell] = []
56
+ for lineno, line in enumerate(lines, start=1):
57
+ for checker in (_check_bidi, _check_zero_width):
58
+ result = checker(line, lineno)
59
+ if result is not None:
60
+ smells.append(result)
61
+ return smells