#!/usr/bin/env node // pre-edit-secrets.mjs // Scans file edits for potential secrets before allowing save (cross-platform, no jq dependency) // // Exit codes: // 0 = Allow edit // 2 = Block edit (secrets detected) import { appendFileSync, mkdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; const input = readFileSync(0, 'utf-8'); const { tool_input } = JSON.parse(input); const content = tool_input?.content ?? tool_input?.new_string ?? ''; const filePath = tool_input?.file_path ?? tool_input?.path ?? ''; if (!content) process.exit(0); // Skip test files, mocks, example/template files if (/\.(test|spec|mock)\.(ts|js|tsx|jsx)$|__tests__|__mocks__/.test(filePath)) { process.exit(0); } if (/\.(example|template|sample)(\..*)?$|\.env\.example|\.env\.template|\.env\.sample/.test(filePath)) { process.exit(0); } // === SECRET PATTERNS === const SECRETS = [ // AWS Keys [/AKIA[0-9A-Z]{16}/, 'AWS Access Key detected in edit.', 'Use environment variables instead.'], // Generic API Keys [/(api[_-]?key|apikey)\s*[:=]\s*["'][a-zA-Z0-9]{20,}["']/, 'Potential API key detected.', 'Use environment variables (process.env.API_KEY) instead.'], // Private keys [/-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/, 'Private key detected in edit.', 'Never commit private keys. Use environment variables or secret managers.'], // JWT secrets [/(jwt[_-]?secret|JWT_SECRET)\s*[:=]\s*["'][^"']{10,}["']/, 'JWT secret detected.', 'Use environment variables instead.'], // Database connection strings with passwords [/(postgres|mysql|mongodb):\/\/[^:]+:[^@]+@/, 'Database connection string with credentials detected.', 'Use environment variables for database URLs.'], // Azure Storage connection strings [/DefaultEndpointsProtocol=https;AccountName=[^;]+;AccountKey=[A-Za-z0-9+/=]{40,}/, 'Azure Storage connection string with AccountKey detected.', 'Use DefaultAzureCredential or environment variables instead.'], // Azure AD / Entra client secrets [/(client[_-]?secret|ClientSecret)\s*[:=]\s*["'][A-Za-z0-9~._-]{34,}["']/, 'Azure AD client secret detected.', 'Use managed identity or environment variables instead.'], // Azure Cognitive Services / AI Services keys [/(Ocp-Apim-Subscription-Key|cognitive[_-]?key|ai[_-]?key)\s*[:=]\s*["'][0-9a-f]{32}["']/, 'Azure AI Services key detected.', 'Use DefaultAzureCredential or environment variables instead.'], // Slack/Discord webhooks [/https:\/\/hooks\.(slack|discord)\.com\/services\/[A-Za-z0-9/]+/, 'Webhook URL detected.', 'Webhook URLs are secrets. Use environment variables.'], // GitHub tokens [/(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}/, 'GitHub token detected.', null], ]; for (const [pattern, reason, advice] of SECRETS) { if (pattern.test(content)) { process.stderr.write(`BLOCKED: ${reason}\n`); if (filePath) process.stderr.write(`File: ${filePath}\n`); if (advice) process.stderr.write(`${advice}\n`); logBlock('secrets', filePath || 'unknown'); process.exit(2); } } // Generic password assignment (warning only, don't block) if (/password\s*[:=]\s*["'][^"']{8,}["']/.test(content)) { if (!/example|placeholder|xxx|your.?password/.test(content)) { process.stderr.write(`WARNING: Possible hardcoded password detected.\n`); if (filePath) process.stderr.write(`File: ${filePath}\n`); process.stderr.write(`Consider using environment variables.\n`); } } process.exit(0); // --- Audit logging --- function logBlock(hook, target) { try { const home = process.env.HOME || process.env.USERPROFILE || ''; const dir = join(home, '.claude', 'audit'); mkdirSync(dir, { recursive: true }); const ts = new Date().toISOString(); appendFileSync(join(dir, 'blocked.log'), `[${ts}] [${hook}] ${target}\n`); } catch { // Audit logging is best-effort } }