#!/usr/bin/env node import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; const RED = '\x1b[0;31m'; const YELLOW = '\x1b[1;33m'; const NC = '\x1b[0m'; const IGNORE_FILE = '.secretscanignore'; const PATTERNS = [ { label: 'AWS Access Key', regex: /AKIA[0-9A-Z]{16}/g }, { label: 'Generic API Key', regex: /(api[_-]?key|apikey|api[_-]?secret)[ \t]*[:=][ \t]*['"][0-9a-zA-Z_./-]{20,}['"]/gi }, { label: 'Generic Secret', regex: /(secret|token|password|passwd|pwd|credential)[ \t]*[:=][ \t]*['"][^\s'"]{8,}['"]/gi }, { label: 'Authorization Header', regex: /(authorization|bearer)[ \t]*[:=][ \t]*['"][^\s'"]{8,}['"]/gi }, { label: 'Private Key', regex: /-----BEGIN\s+(RSA|DSA|EC|OPENSSH|PGP)\s+PRIVATE\s+KEY-----/g }, { label: 'Database URL', regex: /(mysql|postgres|postgresql|mongodb|redis|amqp|mssql):\/\/[^\s'"]{8,}/gi }, { label: 'GitHub Token', regex: /gh[pousr]_[0-9a-zA-Z]{36,}/g }, { label: 'GitLab Token', regex: /glpat-[0-9a-zA-Z-]{20,}/g }, { label: 'Slack Token', regex: /xox[baprs]-[0-9a-zA-Z-]{10,}/g }, { label: 'Slack Webhook', regex: /hooks\.slack\.com\/services\/T[0-9A-Z]{8,}\/B[0-9A-Z]{8,}\/[0-9a-zA-Z]{20,}/g }, { label: 'Google API Key', regex: /AIza[0-9A-Za-z_-]{35}/g }, { label: 'Stripe Key', regex: /[sr]k_(live|test)_[0-9a-zA-Z]{20,}/g }, { label: 'npm Token', regex: /npm_[0-9a-zA-Z]{36,}/g }, { label: 'Hex Secret', regex: /(secret|key|token|password)[ \t]*[:=][ \t]*['"]?[0-9a-f]{32,}['"]?/gi }, { label: 'Hardcoded Password', regex: /password[ \t]*[:=][ \t]*['"][^'"]{4,}['"]/gi }, ]; function runGit(args) { try { return execFileSync('git', args, { encoding: 'utf8', shell: process.platform === 'win32', stdio: ['ignore', 'pipe', 'ignore'], }); } catch { return ''; } } function parseArgs(argv) { if (argv[0] === '--diff') { return { mode: 'diff', ref: argv[1] || 'HEAD' }; } if (argv[0] === '--file') { return { mode: 'file', file: argv[1] || '' }; } return { mode: 'staged' }; } function getFiles(options) { if (options.mode === 'diff') { return runGit(['diff', '--name-only', '--diff-filter=ACMR', options.ref]); } if (options.mode === 'file') { return options.file; } return runGit(['diff', '--cached', '--name-only', '--diff-filter=ACMR']); } function shouldScan(file) { const lower = file.toLowerCase(); const skippedExtensions = [ '.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg', '.woff', '.woff2', '.ttf', '.eot', '.zip', '.tar', '.gz', '.tgz', '.bz2', '.7z', '.rar', '.exe', '.dll', '.so', '.dylib', '.o', '.a', '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.lock', '.map', '.node', '.wasm', ]; if (skippedExtensions.some((ext) => lower.endsWith(ext))) return false; if ( lower === '.secretscanignore' || lower === '.gitignore' || lower === '.gitattributes' || lower.startsWith('license') || lower.startsWith('changelog') || lower.endsWith('.md') || lower === 'package-lock.json' || lower === 'pnpm-lock.yaml' || lower === 'bun.lock' ) { return false; } if ( lower.startsWith('node_modules/') || lower.startsWith('dist/') || lower.startsWith('coverage/') || lower.startsWith('.sf/') ) { return false; } if (lower.endsWith('.min.js') || lower.endsWith('.min.css')) return false; return true; } function getContent(file, mode) { if (mode === 'staged') { const staged = runGit(['show', `:${file}`]); if (staged) return staged; } try { return readFileSync(file, 'utf8'); } catch { return ''; } } function loadIgnorePatterns() { if (!existsSync(IGNORE_FILE)) return []; return readFileSync(IGNORE_FILE, 'utf8') .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line && !line.startsWith('#')); } function isIgnored(file, lineContent, ignorePatterns) { return ignorePatterns.some((pattern) => { const splitIndex = pattern.indexOf(':'); if (splitIndex > 0) { const ignoreFile = pattern.slice(0, splitIndex); const ignoreRegex = pattern.slice(splitIndex + 1); if (file !== ignoreFile) return false; try { return new RegExp(ignoreRegex, 'i').test(lineContent); } catch { return false; } } try { return new RegExp(pattern, 'i').test(lineContent); } catch { return false; } }); } function resetRegex(regex) { regex.lastIndex = 0; return regex; } const options = parseArgs(process.argv.slice(2)); const files = getFiles(options) .split(/\r?\n/) .map((file) => file.trim()) .filter(Boolean); if (files.length === 0) { process.stdout.write('secret-scan: no files to scan\n'); process.exit(0); } const ignorePatterns = loadIgnorePatterns(); let findings = 0; for (const file of files) { if (!shouldScan(file)) continue; const content = getContent(file, options.mode); if (!content) continue; const lines = content.split(/\r?\n/); for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { const line = lines[lineIndex]; for (const pattern of PATTERNS) { if (!resetRegex(pattern.regex).test(line)) continue; if (isIgnored(file, line, ignorePatterns)) continue; process.stdout.write(`${RED}[SECRET DETECTED]${NC} ${YELLOW}${pattern.label}${NC}\n`); process.stdout.write(` File: ${file}:${lineIndex + 1}\n`); process.stdout.write(` Line: ${line.slice(0, 120)}...\n\n`); findings++; } } } if (findings > 0) { process.stdout.write(`${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n`); process.stdout.write(`${RED}Found ${findings} potential secret(s) in scanned files.${NC}\n`); process.stdout.write(`${RED}Commit blocked. Remove the secrets or add exceptions${NC}\n`); process.stdout.write(`${RED}to .secretscanignore if these are false positives.${NC}\n`); process.stdout.write(`${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n`); process.exit(1); } process.stdout.write('secret-scan: no secrets detected ✓\n');