chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"description": "AI-powered bash command security guard. Before any Bash command runs, a lightweight Claude subagent evaluates it for destructive or irreversible patterns — recursive deletes, force pushes to protected branches, database drops, and credential exposure — and blocks execution with a clear explanation if flagged. Uses PreToolUse with type:agent, which is the only hook pattern that can block tool execution via AI reasoning.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "agent",
|
||||
"prompt": "You are a security guard for a developer's terminal. Evaluate the bash command provided in the tool input for destructive or irreversible risk.\n\nDENY the command if it matches ANY of these patterns:\n- Recursive deletion outside /tmp: rm -rf on non-temporary paths\n- Force push to protected branches: git push --force or -f targeting main, master, develop, or production\n- Destructive database operations without a WHERE clause: DROP TABLE, TRUNCATE, DELETE on production-named databases\n- Credential exposure: commands that write environment variables containing KEY, TOKEN, SECRET, or PASSWORD to files or network destinations\n- Disk-level destruction: dd, shred, or mkfs targeting non-loop devices\n\nALLOW everything else, including rm on /tmp, force pushes to personal feature branches, and DROP TABLE in databases named test, dev, or local.\n\nRespond ONLY with a JSON object in exactly this format:\n\nIf DENYING:\n{\"hookSpecificOutput\": {\"hookEventName\": \"PreToolUse\", \"permissionDecision\": \"deny\", \"permissionDecisionReason\": \"<one sentence: what is dangerous and what to do instead>\"}}\n\nIf ALLOWING:\n{\"hookSpecificOutput\": {\"hookEventName\": \"PreToolUse\", \"permissionDecision\": \"allow\"}}",
|
||||
"timeout": 20,
|
||||
"model": "haiku"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"description": "Advanced protection against dangerous shell commands with multi-level security. Blocks catastrophic operations (rm -rf /, dd, mkfs), protects critical paths (.claude/, .git/, node_modules/), and warns about suspicious patterns. Features: catastrophic command blocking, critical path protection, smart pattern detection, and detailed safety messages.",
|
||||
"supportingFiles": [
|
||||
{
|
||||
"source": "dangerous-command-blocker.py",
|
||||
"destination": ".claude/hooks/dangerous-command-blocker.py",
|
||||
"executable": true
|
||||
}
|
||||
],
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 .claude/hooks/dangerous-command-blocker.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Dangerous Command Blocker Hook
|
||||
Multi-level security system for blocking dangerous shell commands
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import re
|
||||
|
||||
# Load command from stdin
|
||||
data = json.load(sys.stdin)
|
||||
cmd = data.get('tool_input', {}).get('command', '')
|
||||
|
||||
# === LEVEL 1: CATASTROPHIC COMMANDS (ALWAYS BLOCK) ===
|
||||
catastrophic_patterns = [
|
||||
(r'\brm\s+.*\s+/\s*$', 'rm on root directory'),
|
||||
(r'\brm\s+.*\s+~\s*$', 'rm on home directory'),
|
||||
(r'\brm\s+.*\s+\*\s*$', 'rm with star wildcard'),
|
||||
(r'\brm\s+-[rfRF]*[rfRF]+.*\*', 'rm -rf with wildcards'),
|
||||
(r'\b(dd\s+if=|dd\s+of=/dev)', 'dd disk operations'),
|
||||
(r'\b(mkfs\.|mkswap\s|fdisk\s)', 'filesystem formatting'),
|
||||
(r'\b:(\(\))?\s*\{\s*:\s*\|\s*:\s*&\s*\}', 'fork bomb'),
|
||||
(r'>\s*/dev/sd[a-z]', 'direct disk write'),
|
||||
(r'\bchmod\s+(-R\s+)?777\s+/', 'chmod 777 on root'),
|
||||
(r'\bchown\s+(-R\s+)?.*\s+/$', 'chown on root directory'),
|
||||
]
|
||||
|
||||
for pattern, desc in catastrophic_patterns:
|
||||
if re.search(pattern, cmd, re.IGNORECASE):
|
||||
print(f'❌ BLOCKED: Catastrophic command detected!', file=sys.stderr)
|
||||
print(f'', file=sys.stderr)
|
||||
print(f'Reason: {desc}', file=sys.stderr)
|
||||
print(f'Command: {cmd[:100]}', file=sys.stderr)
|
||||
print(f'', file=sys.stderr)
|
||||
print(f'This command could cause IRREVERSIBLE system damage or data loss.', file=sys.stderr)
|
||||
print(f'', file=sys.stderr)
|
||||
print(f'Safety tips:', file=sys.stderr)
|
||||
print(f' • Never use rm -rf with /, ~, or * wildcards', file=sys.stderr)
|
||||
print(f' • Avoid recursive operations on system directories', file=sys.stderr)
|
||||
print(f' • Use specific file paths instead of wildcards', file=sys.stderr)
|
||||
print(f' • For cleanup, target specific directories: rm -rf /tmp/myproject', file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# === LEVEL 2: CRITICAL PATH PROTECTION ===
|
||||
critical_paths = [
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?\.claude(/|$|\s)', 'Claude Code configuration'),
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?\.git(/|$|\s)', 'Git repository'),
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?node_modules(/|$|\s)', 'Node.js dependencies'),
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?[^\s]*\.env\b', 'Environment variables'),
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?[^\s]*package\.json\b', 'Package manifest'),
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?[^\s]*package-lock\.json\b', 'Lock file'),
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?[^\s]*yarn\.lock\b', 'Yarn lock file'),
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?[^\s]*Cargo\.toml\b', 'Rust manifest'),
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?[^\s]*go\.mod\b', 'Go module file'),
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?[^\s]*requirements\.txt\b', 'Python dependencies'),
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?[^\s]*Gemfile(\.lock)?\b', 'Ruby dependencies'),
|
||||
(r'\b(rm|mv)\s+(-[rfRF]+\s+)?[^\s]*composer\.json\b', 'PHP dependencies'),
|
||||
]
|
||||
|
||||
for pattern, desc in critical_paths:
|
||||
if re.search(pattern, cmd, re.IGNORECASE):
|
||||
print(f'🛑 BLOCKED: Critical path protection activated!', file=sys.stderr)
|
||||
print(f'', file=sys.stderr)
|
||||
print(f'Protected resource: {desc}', file=sys.stderr)
|
||||
print(f'Command: {cmd[:100]}', file=sys.stderr)
|
||||
print(f'', file=sys.stderr)
|
||||
print(f'This path contains critical project files that should not be deleted accidentally.', file=sys.stderr)
|
||||
print(f'', file=sys.stderr)
|
||||
print(f'If you really need to modify this:', file=sys.stderr)
|
||||
print(f' 1. Disable the hook temporarily in .claude/hooks.json', file=sys.stderr)
|
||||
print(f' 2. Execute the command manually in your terminal', file=sys.stderr)
|
||||
print(f' 3. Or modify specific files instead of using rm/mv on entire directories', file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# === LEVEL 3: SUSPICIOUS PATTERNS (WARNING) ===
|
||||
suspicious_patterns = [
|
||||
(r'\brm\s+.*\s+&&', 'chained rm commands'),
|
||||
(r'\brm\s+[^\s/]*\*', 'rm with wildcards'),
|
||||
(r'\bfind\s+.*-delete', 'find -delete operation'),
|
||||
(r'\bxargs\s+.*\brm', 'xargs with rm'),
|
||||
]
|
||||
|
||||
for pattern, desc in suspicious_patterns:
|
||||
if re.search(pattern, cmd, re.IGNORECASE):
|
||||
print(f'⚠️ WARNING: Suspicious command pattern detected!', file=sys.stderr)
|
||||
print(f'', file=sys.stderr)
|
||||
print(f'Pattern: {desc}', file=sys.stderr)
|
||||
print(f'Command: {cmd[:100]}', file=sys.stderr)
|
||||
print(f'', file=sys.stderr)
|
||||
print(f'This command uses patterns that could accidentally delete more than intended.', file=sys.stderr)
|
||||
print(f'Consider reviewing the command carefully before execution.', file=sys.stderr)
|
||||
print(f'', file=sys.stderr)
|
||||
# Exit 0 to allow but with warning
|
||||
sys.exit(0)
|
||||
|
||||
# Command is safe
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"description": "Prevent writing to .env files using the if condition for lightweight filtering. Blocks any Write tool call targeting .env* files, protecting secrets from accidental overwrites. Uses the if field to avoid spawning a process unless the file pattern matches.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Write(.env*)",
|
||||
"command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Writing to .env files is blocked by hook\"}}'"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Protect critical files from accidental modification. Prevents editing of important system files, configuration files, and production code.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit|MultiEdit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "PROTECTED_PATTERNS=('*/etc/*' '*/usr/bin/*' '*/usr/sbin/*' '*.production.*' '*prod*config*' '*/node_modules/*' '*/vendor/*'); for pattern in \"${PROTECTED_PATTERNS[@]}\"; do if [[ \"$CLAUDE_TOOL_FILE_PATH\" == $pattern ]]; then echo \"Error: File $CLAUDE_TOOL_FILE_PATH is protected from modification\" >&2; exit 1; fi; done"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"description": "Block git force push commands using the if condition for efficient filtering. Prevents accidental force pushes that can overwrite remote history. Covers --force, --force-with-lease, and the -f shorthand.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(git push *--force*)",
|
||||
"command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Force push is blocked by hook\"}}'"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Bash(git push *-f*)",
|
||||
"command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Force push (-f) is blocked by hook\"}}'"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Automatically detects hardcoded secrets before git commits. Scans for API keys from 30+ providers (Anthropic: sk-ant-..., OpenAI: sk-..., AWS: AKIA..., Stripe: sk_live_..., Google: AIza..., GitHub: ghp_..., Vercel, Supabase, Hugging Face: hf_..., Replicate: r8_..., Groq: gsk_..., Databricks: dapi..., GitLab, DigitalOcean, npm, PyPI, and more), tokens, passwords, private keys, and database credentials. Blocks commits containing secrets and suggests using environment variables instead.",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/secret-scanner.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Secret Scanner Hook
|
||||
Detects hardcoded secrets before git commits
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import re
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
# Secret detection patterns with descriptions
|
||||
SECRET_PATTERNS = [
|
||||
# AWS Keys
|
||||
(r'AKIA[0-9A-Z]{16}', 'AWS Access Key ID', 'high'),
|
||||
(r'(?i)aws[_\-\s]*secret[_\-\s]*access[_\-\s]*key[\'"\s]*[=:][\'"\s]*[A-Za-z0-9/+=]{40}', 'AWS Secret Access Key', 'high'),
|
||||
|
||||
# Anthropic (Claude) API Keys
|
||||
(r'sk-ant-api\d{2}-[A-Za-z0-9\-_]{20,}', 'Anthropic API Key', 'high'),
|
||||
|
||||
# OpenAI API Keys
|
||||
(r'sk-[a-zA-Z0-9]{48,}', 'OpenAI API Key', 'high'),
|
||||
(r'sk-proj-[a-zA-Z0-9\-_]{32,}', 'OpenAI Project API Key', 'high'),
|
||||
|
||||
# Google API Keys & Service Accounts
|
||||
(r'AIza[0-9A-Za-z\-_]{35}', 'Google API Key', 'high'),
|
||||
(r'ya29\.[0-9A-Za-z\-_]+', 'Google OAuth Access Token', 'high'),
|
||||
|
||||
# Stripe API Keys
|
||||
(r'sk_live_[0-9a-zA-Z]{24,}', 'Stripe Live Secret Key', 'critical'),
|
||||
(r'sk_test_[0-9a-zA-Z]{24,}', 'Stripe Test Secret Key', 'medium'),
|
||||
(r'rk_live_[0-9a-zA-Z]{24,}', 'Stripe Live Restricted Key', 'high'),
|
||||
(r'pk_live_[0-9a-zA-Z]{24,}', 'Stripe Live Publishable Key', 'medium'),
|
||||
|
||||
# GitHub Tokens
|
||||
(r'ghp_[0-9a-zA-Z]{36}', 'GitHub Personal Access Token', 'high'),
|
||||
(r'gho_[0-9a-zA-Z]{36}', 'GitHub OAuth Token', 'high'),
|
||||
(r'ghs_[0-9a-zA-Z]{36}', 'GitHub App Secret', 'high'),
|
||||
(r'ghr_[0-9a-zA-Z]{36}', 'GitHub Refresh Token', 'high'),
|
||||
(r'github_pat_[0-9a-zA-Z_]{22,}', 'GitHub Fine-Grained PAT', 'high'),
|
||||
|
||||
# GitLab Tokens
|
||||
(r'glpat-[0-9a-zA-Z\-_]{20,}', 'GitLab Personal Access Token', 'high'),
|
||||
|
||||
# Vercel Tokens
|
||||
(r'vercel_[0-9a-zA-Z_\-]{24,}', 'Vercel Token', 'high'),
|
||||
|
||||
# Supabase Keys
|
||||
(r'sbp_[0-9a-f]{40}', 'Supabase Service Key', 'high'),
|
||||
(r'sb_publishable_[A-Za-z0-9\-_]{20,}', 'Supabase Publishable Key', 'medium'),
|
||||
(r'sb_secret_[A-Za-z0-9\-_]{20,}', 'Supabase Secret Key', 'high'),
|
||||
|
||||
# Hugging Face Tokens
|
||||
(r'hf_[a-zA-Z0-9]{34,}', 'Hugging Face Token', 'high'),
|
||||
|
||||
# Replicate API Tokens
|
||||
(r'r8_[a-zA-Z0-9]{38,}', 'Replicate API Token', 'high'),
|
||||
|
||||
# Groq API Keys
|
||||
(r'gsk_[a-zA-Z0-9]{48,}', 'Groq API Key', 'high'),
|
||||
|
||||
# Databricks Personal Access Tokens
|
||||
(r'dapi[0-9a-f]{32}', 'Databricks Access Token', 'high'),
|
||||
|
||||
# Azure Keys
|
||||
(r'(?i)azure[_\-\s]*(?:key|secret|token)[\'"\s]*[=:][\'"\s]*[A-Za-z0-9+/=]{32,}', 'Azure Key', 'high'),
|
||||
|
||||
# Cloudflare API Tokens
|
||||
(r'(?:cf|cloudflare)[_\-]?[A-Za-z0-9_\-]{37,}', 'Cloudflare API Token', 'medium'),
|
||||
|
||||
# DigitalOcean Tokens
|
||||
(r'dop_v1_[0-9a-f]{64}', 'DigitalOcean Personal Access Token', 'high'),
|
||||
(r'doo_v1_[0-9a-f]{64}', 'DigitalOcean OAuth Token', 'high'),
|
||||
|
||||
# Linear API Keys
|
||||
(r'lin_api_[a-zA-Z0-9]{40,}', 'Linear API Key', 'high'),
|
||||
|
||||
# Notion API Keys
|
||||
(r'ntn_[0-9a-zA-Z]{40,}', 'Notion Integration Token', 'high'),
|
||||
(r'secret_[0-9a-zA-Z]{43}', 'Notion API Key (legacy)', 'high'),
|
||||
|
||||
# Figma Access Tokens
|
||||
(r'figd_[0-9a-zA-Z\-_]{40,}', 'Figma Access Token', 'high'),
|
||||
|
||||
# npm Tokens
|
||||
(r'npm_[0-9a-zA-Z]{36,}', 'npm Access Token', 'high'),
|
||||
|
||||
# PyPI API Tokens
|
||||
(r'pypi-[A-Za-z0-9\-_]{16,}', 'PyPI API Token', 'high'),
|
||||
|
||||
# Generic API Keys
|
||||
(r'(?i)(api[_\-\s]*key|apikey)[\'"\s]*[=:][\'"\s]*[\'"][0-9a-zA-Z\-_]{20,}[\'"]', 'Generic API Key', 'medium'),
|
||||
(r'(?i)(secret[_\-\s]*key|secretkey)[\'"\s]*[=:][\'"\s]*[\'"][0-9a-zA-Z\-_]{20,}[\'"]', 'Generic Secret Key', 'medium'),
|
||||
(r'(?i)(access[_\-\s]*token|accesstoken)[\'"\s]*[=:][\'"\s]*[\'"][0-9a-zA-Z\-_]{20,}[\'"]', 'Generic Access Token', 'medium'),
|
||||
|
||||
# Passwords
|
||||
(r'(?i)password[\'"\s]*[=:][\'"\s]*[\'"][^\'"\s]{8,}[\'"]', 'Hardcoded Password', 'high'),
|
||||
(r'(?i)passwd[\'"\s]*[=:][\'"\s]*[\'"][^\'"\s]{8,}[\'"]', 'Hardcoded Password', 'high'),
|
||||
|
||||
# Private Keys
|
||||
(r'-----BEGIN (RSA |DSA |EC )?PRIVATE KEY-----', 'Private Key', 'critical'),
|
||||
(r'-----BEGIN OPENSSH PRIVATE KEY-----', 'OpenSSH Private Key', 'critical'),
|
||||
|
||||
# Database Connection Strings
|
||||
(r'(?i)(mysql|postgresql|postgres|mongodb)://[^\s\'"\)]+:[^\s\'"\)]+@', 'Database Connection String', 'high'),
|
||||
(r'(?i)Server=[^;]+;Database=[^;]+;User Id=[^;]+;Password=[^;]+', 'SQL Server Connection String', 'high'),
|
||||
|
||||
# JWT Tokens
|
||||
(r'eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}', 'JWT Token', 'medium'),
|
||||
|
||||
# Slack Tokens
|
||||
(r'xox[baprs]-[0-9a-zA-Z\-]{10,}', 'Slack Token', 'high'),
|
||||
|
||||
# Telegram Bot Tokens
|
||||
(r'[0-9]{8,10}:[A-Za-z0-9_\-]{35}', 'Telegram Bot Token', 'medium'),
|
||||
|
||||
# Discord Webhooks
|
||||
(r'https://discord\.com/api/webhooks/[0-9]+/[A-Za-z0-9_\-]+', 'Discord Webhook URL', 'medium'),
|
||||
|
||||
# Twilio API Keys
|
||||
(r'SK[0-9a-fA-F]{32}', 'Twilio API Key', 'high'),
|
||||
|
||||
# SendGrid API Keys
|
||||
(r'SG\.[A-Za-z0-9_\-]{22}\.[A-Za-z0-9_\-]{43}', 'SendGrid API Key', 'high'),
|
||||
|
||||
# Mailgun API Keys
|
||||
(r'key-[0-9a-zA-Z]{32}', 'Mailgun API Key', 'medium'),
|
||||
|
||||
# Heroku API Keys
|
||||
(r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}', 'Potential API Key (UUID format)', 'low'),
|
||||
]
|
||||
|
||||
# Files to exclude from scanning
|
||||
EXCLUDED_FILES = [
|
||||
'.env.example',
|
||||
'.env.sample',
|
||||
'.env.template',
|
||||
'package-lock.json',
|
||||
'yarn.lock',
|
||||
'poetry.lock',
|
||||
'Pipfile.lock',
|
||||
'Cargo.lock',
|
||||
'go.sum',
|
||||
'.gitignore',
|
||||
]
|
||||
|
||||
# Directories to exclude
|
||||
EXCLUDED_DIRS = [
|
||||
'node_modules/',
|
||||
'vendor/',
|
||||
'.git/',
|
||||
'dist/',
|
||||
'build/',
|
||||
'__pycache__/',
|
||||
'.pytest_cache/',
|
||||
'venv/',
|
||||
'env/',
|
||||
]
|
||||
|
||||
def should_skip_file(file_path):
|
||||
"""Check if file should be skipped"""
|
||||
# Skip if file doesn't exist (might be deleted)
|
||||
if not os.path.exists(file_path):
|
||||
return True
|
||||
|
||||
# Skip excluded files
|
||||
filename = os.path.basename(file_path)
|
||||
if filename in EXCLUDED_FILES:
|
||||
return True
|
||||
|
||||
# Skip excluded directories
|
||||
for excluded_dir in EXCLUDED_DIRS:
|
||||
if excluded_dir in file_path:
|
||||
return True
|
||||
|
||||
# Skip binary files
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
chunk = f.read(1024)
|
||||
if b'\0' in chunk:
|
||||
return True
|
||||
except:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_staged_files():
|
||||
"""Get list of staged files"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['git', 'diff', '--cached', '--name-only', '--diff-filter=ACM'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
return [f.strip() for f in result.stdout.split('\n') if f.strip()]
|
||||
except subprocess.CalledProcessError:
|
||||
return []
|
||||
|
||||
def scan_file(file_path):
|
||||
"""Scan a single file for secrets"""
|
||||
findings = []
|
||||
|
||||
if should_skip_file(file_path):
|
||||
return findings
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
|
||||
for line_num, line in enumerate(content.split('\n'), 1):
|
||||
for pattern, description, severity in SECRET_PATTERNS:
|
||||
matches = re.finditer(pattern, line)
|
||||
for match in matches:
|
||||
# Skip if it looks like a comment or example
|
||||
line_stripped = line.strip()
|
||||
if line_stripped.startswith('#') or line_stripped.startswith('//'):
|
||||
# Check if it's actually a comment with example
|
||||
if 'example' in line_stripped.lower() or 'placeholder' in line_stripped.lower():
|
||||
continue
|
||||
|
||||
findings.append({
|
||||
'file': file_path,
|
||||
'line': line_num,
|
||||
'description': description,
|
||||
'severity': severity,
|
||||
'match': match.group(0)[:50] + '...' if len(match.group(0)) > 50 else match.group(0),
|
||||
'full_line': line.strip()[:100]
|
||||
})
|
||||
except Exception as e:
|
||||
# Skip files that can't be read
|
||||
pass
|
||||
|
||||
return findings
|
||||
|
||||
def print_findings(findings):
|
||||
"""Print findings in a formatted way"""
|
||||
if not findings:
|
||||
return
|
||||
|
||||
# Sort by severity
|
||||
severity_order = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3}
|
||||
findings.sort(key=lambda x: (severity_order.get(x['severity'], 4), x['file'], x['line']))
|
||||
|
||||
print('', file=sys.stderr)
|
||||
print('🚨 SECRET SCANNER: Potential secrets detected!', file=sys.stderr)
|
||||
print('', file=sys.stderr)
|
||||
|
||||
critical_count = sum(1 for f in findings if f['severity'] == 'critical')
|
||||
high_count = sum(1 for f in findings if f['severity'] == 'high')
|
||||
medium_count = sum(1 for f in findings if f['severity'] == 'medium')
|
||||
low_count = sum(1 for f in findings if f['severity'] == 'low')
|
||||
|
||||
print(f'Found {len(findings)} potential secret(s):', file=sys.stderr)
|
||||
if critical_count > 0:
|
||||
print(f' 🔴 Critical: {critical_count}', file=sys.stderr)
|
||||
if high_count > 0:
|
||||
print(f' 🟠 High: {high_count}', file=sys.stderr)
|
||||
if medium_count > 0:
|
||||
print(f' 🟡 Medium: {medium_count}', file=sys.stderr)
|
||||
if low_count > 0:
|
||||
print(f' 🔵 Low: {low_count}', file=sys.stderr)
|
||||
print('', file=sys.stderr)
|
||||
|
||||
for finding in findings:
|
||||
severity_emoji = {
|
||||
'critical': '🔴',
|
||||
'high': '🟠',
|
||||
'medium': '🟡',
|
||||
'low': '🔵'
|
||||
}.get(finding['severity'], '⚪')
|
||||
|
||||
print(f'{severity_emoji} {finding["description"]}', file=sys.stderr)
|
||||
print(f' File: {finding["file"]}:{finding["line"]}', file=sys.stderr)
|
||||
print(f' Match: {finding["match"]}', file=sys.stderr)
|
||||
print('', file=sys.stderr)
|
||||
|
||||
print('❌ COMMIT BLOCKED: Remove secrets before committing', file=sys.stderr)
|
||||
print('', file=sys.stderr)
|
||||
print('How to fix:', file=sys.stderr)
|
||||
print(' 1. Move secrets to environment variables:', file=sys.stderr)
|
||||
print(' • Create/update .env file (ensure it\'s in .gitignore)', file=sys.stderr)
|
||||
print(' • Use process.env.SECRET_NAME (Node.js) or os.environ.get("SECRET_NAME") (Python)', file=sys.stderr)
|
||||
print('', file=sys.stderr)
|
||||
print(' 2. Use a secrets management service:', file=sys.stderr)
|
||||
print(' • AWS Secrets Manager, Google Secret Manager, Azure Key Vault', file=sys.stderr)
|
||||
print(' • HashiCorp Vault, Doppler, 1Password', file=sys.stderr)
|
||||
print('', file=sys.stderr)
|
||||
print(' 3. For false positives:', file=sys.stderr)
|
||||
print(' • Add comments with "example" or "placeholder" to skip detection', file=sys.stderr)
|
||||
print(' • Disable hook temporarily: remove from .claude/hooks.json', file=sys.stderr)
|
||||
print('', file=sys.stderr)
|
||||
|
||||
def main():
|
||||
# Read hook input from stdin (Claude Code passes JSON via stdin)
|
||||
try:
|
||||
input_data = json.load(sys.stdin)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# If no valid JSON on stdin, allow the action
|
||||
sys.exit(0)
|
||||
|
||||
# Only act on git commit commands
|
||||
tool_input = input_data.get('tool_input', {})
|
||||
command = tool_input.get('command', '')
|
||||
if not re.search(r'git\s+commit', command):
|
||||
sys.exit(0)
|
||||
|
||||
# Get staged files
|
||||
staged_files = get_staged_files()
|
||||
|
||||
# PreToolUse runs before the command, so files may not be staged yet.
|
||||
# Handle two cases:
|
||||
# 1. git commit -a/-am: scans tracked modified files (what -a would stage)
|
||||
# 2. git add ... && git commit: scans files from the git add part
|
||||
if not staged_files:
|
||||
# Check if commit uses -a flag (auto-stage tracked modified files)
|
||||
commit_match = re.search(r'git\s+commit\s+(.+)', command)
|
||||
if commit_match and re.search(r'-\w*a', commit_match.group(1)):
|
||||
result = subprocess.run(
|
||||
['git', 'diff', '--name-only'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
for f in result.stdout.strip().split('\n'):
|
||||
if f.strip() and os.path.isfile(f.strip()):
|
||||
staged_files.append(f.strip())
|
||||
|
||||
# Check for chained git add ... && git commit
|
||||
for part in re.split(r'&&|;', command):
|
||||
part = part.strip()
|
||||
add_match = re.match(r'git\s+add\s+(.+)', part)
|
||||
if add_match:
|
||||
args = add_match.group(1).strip()
|
||||
if args in ('.', '-A', '--all'):
|
||||
result = subprocess.run(
|
||||
['git', 'status', '--porcelain'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if line and len(line) > 3:
|
||||
f = line[3:].strip()
|
||||
if os.path.isfile(f):
|
||||
staged_files.append(f)
|
||||
else:
|
||||
for token in args.split():
|
||||
if not token.startswith('-') and os.path.isfile(token):
|
||||
staged_files.append(token)
|
||||
|
||||
if not staged_files:
|
||||
sys.exit(0)
|
||||
|
||||
# Scan all staged files
|
||||
all_findings = []
|
||||
for file_path in staged_files:
|
||||
findings = scan_file(file_path)
|
||||
all_findings.extend(findings)
|
||||
|
||||
# If we found any secrets, block the commit
|
||||
if all_findings:
|
||||
print_findings(all_findings)
|
||||
sys.exit(2)
|
||||
|
||||
# No secrets found, allow commit
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"description": "Scan code for security vulnerabilities and secrets after modifications. Uses multiple security tools to detect potential issues.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "if command -v semgrep >/dev/null 2>&1; then semgrep --config=auto \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; fi; if command -v bandit >/dev/null 2>&1 && [[ \"$CLAUDE_TOOL_FILE_PATH\" == *.py ]]; then bandit \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null || true; fi; if command -v gitleaks >/dev/null 2>&1; then gitleaks detect --source=\"$CLAUDE_TOOL_FILE_PATH\" --no-git 2>/dev/null || true; fi; if grep -qE '(password|secret|key|token)\\s*=\\s*[\"\\x27][^\"\\x27]{8,}' \"$CLAUDE_TOOL_FILE_PATH\" 2>/dev/null; then echo \"Warning: Potential hardcoded secrets detected in $CLAUDE_TOOL_FILE_PATH\" >&2; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"description": "Detects destructive commands hidden inside shell wrappers (sh -c, bash -c, python3 -c, node -e, perl -e, ruby -e). Complements dangerous-command-blocker by catching bypass vectors like 'sh -c \"rm -rf /\"' that evade direct command checks. Covers 8 bypass patterns: interpreter one-liners, nested wrappers, pipe-to-shell, here-strings, and env-based wrappers.",
|
||||
"supportingFiles": [
|
||||
{
|
||||
"source": "shell-wrapper-guard.sh",
|
||||
"destination": ".claude/hooks/shell-wrapper-guard.sh",
|
||||
"executable": true
|
||||
}
|
||||
],
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash .claude/hooks/shell-wrapper-guard.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/bin/bash
|
||||
# shell-wrapper-guard.sh — Detect destructive commands hidden in shell wrappers
|
||||
#
|
||||
# Solves: Bypass vectors that evade destructive-guard by wrapping commands:
|
||||
# sh -c "rm -rf /"
|
||||
# bash -c "git reset --hard"
|
||||
# python3 -c "import os; os.system('rm -rf ~')"
|
||||
# perl -e "system('rm -rf /')"
|
||||
# ruby -e "system('rm -rf /')"
|
||||
# node -e "require('child_process').execSync('rm -rf /')"
|
||||
#
|
||||
# Complements destructive-guard.sh which checks direct commands.
|
||||
# This hook unwraps interpreter one-liners and checks the inner command.
|
||||
#
|
||||
# Usage: PreToolUse hook on "Bash"
|
||||
#
|
||||
# TRIGGER: PreToolUse MATCHER: "Bash"
|
||||
|
||||
INPUT=$(cat)
|
||||
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
||||
[ -z "$COMMAND" ] && exit 0
|
||||
|
||||
# Destructive patterns to detect inside wrappers
|
||||
DESTRUCT_PATTERN='rm\s+-[rf]*\s+[/~]|rm\s+-[rf]*\s+\.\.|git\s+reset\s+--hard|git\s+clean\s+-[fd]+|git\s+checkout\s+\.|mkfs\.|dd\s+if=|>\s*/dev/sd|chmod\s+777\s+/'
|
||||
|
||||
# === Check 1: sh/bash -c wrappers ===
|
||||
if echo "$COMMAND" | grep -qE '(sh|bash|zsh|dash)\s+-c\s+'; then
|
||||
INNER=$(echo "$COMMAND" | sed -E "s/.*(sh|bash|zsh|dash)\s+-c\s+['\"]?//" | sed "s/['\"]?\s*$//")
|
||||
if echo "$INNER" | grep -qE "$DESTRUCT_PATTERN"; then
|
||||
echo "BLOCKED: Destructive command hidden in shell wrapper" >&2
|
||||
echo " Detected: $INNER" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# === Check 2: Python one-liners ===
|
||||
if echo "$COMMAND" | grep -qE 'python[23]?(\.[0-9]+)?\s+-c\s+'; then
|
||||
INNER=$(echo "$COMMAND" | sed -E "s/.*python[23]?(\.[0-9]+)?\s+-c\s+['\"]?//" | sed "s/['\"]?\s*$//")
|
||||
if echo "$INNER" | grep -qiE "os\.system\(.*($DESTRUCT_PATTERN)|subprocess\.(run|call|Popen)\(.*($DESTRUCT_PATTERN)|shutil\.rmtree\s*\(\s*['\"/~]"; then
|
||||
echo "BLOCKED: Destructive command in Python one-liner" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# === Check 3: Perl/Ruby one-liners ===
|
||||
if echo "$COMMAND" | grep -qE '(perl|ruby)\s+-e\s+'; then
|
||||
INNER=$(echo "$COMMAND" | sed -E "s/.*(perl|ruby)\s+-e\s+['\"]?//" | sed "s/['\"]?\s*$//")
|
||||
if echo "$INNER" | grep -qE "system\(.*($DESTRUCT_PATTERN)|exec\(.*($DESTRUCT_PATTERN)"; then
|
||||
echo "BLOCKED: Destructive command in interpreter one-liner" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# === Check 4: Node.js one-liners ===
|
||||
if echo "$COMMAND" | grep -qE 'node\s+-e\s+'; then
|
||||
INNER=$(echo "$COMMAND" | sed -E "s/.*node\s+-e\s+['\"]?//" | sed "s/['\"]?\s*$//")
|
||||
if echo "$INNER" | grep -qE "execSync\(.*($DESTRUCT_PATTERN)|exec\(.*($DESTRUCT_PATTERN)"; then
|
||||
echo "BLOCKED: Destructive command in Node.js one-liner" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# === Check 5: Nested wrappers (sh -c "bash -c 'rm -rf /'") ===
|
||||
if echo "$COMMAND" | grep -qE '(sh|bash)\s+-c\s+.*(sh|bash)\s+-c'; then
|
||||
if echo "$COMMAND" | grep -qE "$DESTRUCT_PATTERN"; then
|
||||
echo "BLOCKED: Nested shell wrapper with destructive command" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# === Check 6: Pipe to shell (echo "rm -rf /" | sh) ===
|
||||
if echo "$COMMAND" | grep -qE '\|\s*(sh|bash|zsh)(\s|$)'; then
|
||||
# Extract the piped content (handles "| sh", "| sh -s", "| bash -c ...")
|
||||
PIPED=$(echo "$COMMAND" | sed -E 's/\s*\|\s*(sh|bash|zsh)(\s.*)?$//')
|
||||
if echo "$PIPED" | grep -qE "$DESTRUCT_PATTERN"; then
|
||||
echo "BLOCKED: Destructive command piped to shell" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# === Check 7: Here-string to shell (bash <<< "rm -rf /") ===
|
||||
if echo "$COMMAND" | grep -qE '(sh|bash|zsh)\s+<<<\s+'; then
|
||||
INNER=$(echo "$COMMAND" | sed -E "s/.*(sh|bash|zsh)\s+<<<\s+['\"]?//" | sed "s/['\"]?\s*$//")
|
||||
if echo "$INNER" | grep -qE "$DESTRUCT_PATTERN"; then
|
||||
echo "BLOCKED: Destructive command via here-string" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
# === Check 8: env-based bypass (env VAR=val sh -c "$VAR") ===
|
||||
if echo "$COMMAND" | grep -qE '^\s*env\s+.*\s+(sh|bash)\s+-c'; then
|
||||
if echo "$COMMAND" | grep -qE "$DESTRUCT_PATTERN"; then
|
||||
echo "BLOCKED: Destructive command via env wrapper" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Reference in New Issue
Block a user