Files
wehub-resource-sync bb5c75ce05
Component Security Validation / Security Audit (push) Has been cancelled
Deploy to Cloudflare Pages / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:38:58 +08:00

368 lines
13 KiB
Python

#!/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()