@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.
- package/LICENSE +21 -0
- package/README.md +88 -37
- package/bin/claude-commands +307 -65
- package/commands/active/xarchitecture.md +393 -0
- package/commands/active/xconfig.md +127 -0
- package/commands/active/xcontinue.md +92 -0
- package/commands/active/xdebug.md +130 -0
- package/commands/active/xdocs.md +178 -0
- package/commands/active/xexplore.md +94 -0
- package/commands/active/xgit.md +149 -0
- package/commands/active/xpipeline.md +152 -0
- package/commands/active/xquality.md +96 -0
- package/commands/active/xrefactor.md +198 -0
- package/commands/active/xrelease.md +142 -0
- package/commands/active/xsecurity.md +92 -0
- package/commands/active/xspec.md +174 -0
- package/commands/active/xtdd.md +151 -0
- package/commands/active/xtest.md +89 -0
- package/commands/active/xverify.md +80 -0
- package/commands/experiments/xact.md +742 -0
- package/commands/experiments/xanalytics.md +113 -0
- package/commands/experiments/xanalyze.md +70 -0
- package/commands/experiments/xapi.md +161 -0
- package/commands/experiments/xatomic.md +112 -0
- package/commands/experiments/xaws.md +85 -0
- package/commands/experiments/xcicd.md +337 -0
- package/commands/experiments/xcommit.md +122 -0
- package/commands/experiments/xcompliance.md +182 -0
- package/commands/experiments/xconstraints.md +89 -0
- package/commands/experiments/xcoverage.md +90 -0
- package/commands/experiments/xdb.md +102 -0
- package/commands/experiments/xdesign.md +121 -0
- package/commands/experiments/xdevcontainer.md +238 -0
- package/commands/experiments/xevaluate.md +111 -0
- package/commands/experiments/xfootnote.md +12 -0
- package/commands/experiments/xgenerate.md +117 -0
- package/commands/experiments/xgovernance.md +149 -0
- package/commands/experiments/xgreen.md +66 -0
- package/commands/experiments/xiac.md +118 -0
- package/commands/experiments/xincident.md +137 -0
- package/commands/experiments/xinfra.md +115 -0
- package/commands/experiments/xknowledge.md +115 -0
- package/commands/experiments/xmaturity.md +120 -0
- package/commands/experiments/xmetrics.md +118 -0
- package/commands/experiments/xmonitoring.md +128 -0
- package/commands/experiments/xnew.md +903 -0
- package/commands/experiments/xobservable.md +114 -0
- package/commands/experiments/xoidc.md +165 -0
- package/commands/experiments/xoptimize.md +115 -0
- package/commands/experiments/xperformance.md +112 -0
- package/commands/experiments/xplanning.md +131 -0
- package/commands/experiments/xpolicy.md +115 -0
- package/commands/experiments/xproduct.md +98 -0
- package/commands/experiments/xreadiness.md +75 -0
- package/commands/experiments/xred.md +55 -0
- package/commands/experiments/xrisk.md +128 -0
- package/commands/experiments/xrules.md +124 -0
- package/commands/experiments/xsandbox.md +120 -0
- package/commands/experiments/xscan.md +102 -0
- package/commands/experiments/xsetup.md +123 -0
- package/commands/experiments/xtemplate.md +116 -0
- package/commands/experiments/xtrace.md +212 -0
- package/commands/experiments/xux.md +171 -0
- package/commands/experiments/xvalidate.md +104 -0
- package/commands/experiments/xworkflow.md +113 -0
- package/hooks/.smellrc.example.json +19 -0
- package/hooks/README.md +263 -0
- package/hooks/check-commit-signing.py +127 -0
- package/hooks/check-complexity.py +38 -0
- package/hooks/check-security.py +37 -0
- package/hooks/claude-wrapper.sh +29 -0
- package/hooks/config.py +110 -0
- package/hooks/file-logger.sh +100 -0
- package/hooks/lib/argument-parser.sh +427 -0
- package/hooks/lib/config-constants.sh +230 -0
- package/hooks/lib/context-manager.sh +560 -0
- package/hooks/lib/error-handler.sh +423 -0
- package/hooks/lib/execution-engine.sh +444 -0
- package/hooks/lib/execution-results.sh +113 -0
- package/hooks/lib/execution-simulation.sh +114 -0
- package/hooks/lib/field-validators.sh +104 -0
- package/hooks/lib/file-utils.sh +398 -0
- package/hooks/lib/subagent-discovery.sh +468 -0
- package/hooks/lib/subagent-validator.sh +407 -0
- package/hooks/lib/validation-reporter.sh +134 -0
- package/hooks/on-error-debug.sh +226 -0
- package/hooks/pre-commit-quality.sh +204 -0
- package/hooks/pre-commit-test-runner.sh +132 -0
- package/hooks/pre-write-security.sh +115 -0
- package/hooks/prevent-credential-exposure.sh +279 -0
- package/hooks/security_bandit.py +177 -0
- package/hooks/security_checks.py +97 -0
- package/hooks/security_secrets.py +81 -0
- package/hooks/security_trojan.py +61 -0
- package/hooks/settings.example.json +52 -0
- package/hooks/smell_checks.py +238 -0
- package/hooks/smell_javascript.py +231 -0
- package/hooks/smell_python.py +110 -0
- package/hooks/smell_ruff.py +70 -0
- package/hooks/smell_types.py +72 -0
- package/hooks/subagent-trigger-simple.sh +202 -0
- package/hooks/subagent-trigger.sh +253 -0
- package/hooks/suppression.py +82 -0
- package/hooks/tab-color.sh +70 -0
- package/hooks/verify-before-edit.sh +135 -0
- package/lib/backup-restore-command.js +140 -0
- package/lib/base/base-command.js +252 -0
- package/lib/base/command-result.js +184 -0
- package/lib/config/constants.js +255 -0
- package/lib/config.js +48 -6
- package/lib/configure-command.js +428 -0
- package/lib/dependency-validator.js +64 -5
- package/lib/hook-installer-core.js +2 -2
- package/lib/installation-instruction-generator.js +213 -495
- package/lib/installer.js +134 -56
- package/lib/oidc-command.js +740 -0
- package/lib/services/backup-list-service.js +226 -0
- package/lib/services/backup-service.js +230 -0
- package/lib/services/command-installer-service.js +217 -0
- package/lib/services/logger-service.js +201 -0
- package/lib/services/package-manager-service.js +319 -0
- package/lib/services/platform-instruction-service.js +294 -0
- package/lib/services/recovery-instruction-service.js +348 -0
- package/lib/services/restore-service.js +221 -0
- package/lib/setup-command.js +359 -0
- package/lib/setup-wizard.js +155 -262
- package/lib/uninstall-command.js +100 -0
- package/lib/utils/claude-path-config.js +184 -0
- package/lib/utils/file-system-utils.js +152 -0
- package/lib/utils.js +8 -4
- package/lib/verify-command.js +430 -0
- package/package.json +7 -3
- package/scripts/postinstall.js +172 -157
- package/subagents/debug-specialist.md +7 -0
- package/templates/README.md +115 -0
- package/templates/basic-settings.json +30 -0
- package/templates/comprehensive-settings.json +57 -0
- package/templates/global-claude.md +344 -0
- package/templates/hybrid-hook-config.yaml +132 -0
- package/templates/security-focused-settings.json +62 -0
- package/templates/subagent-hooks.yaml +188 -0
- package/lib/package-manager-service.js +0 -270
- 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
|