singularity-forge/scripts/secret-scan.mjs
ace-pm 6b0ac484ba refactor: update log prefixes and string values from gsd- to sf- namespace
Updates channel prefixes, log messages, comments, and configuration values
across daemon, mcp-server, and related packages to complete the rebrand from
gsd to sf-run naming.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:37:12 +02:00

184 lines
6.1 KiB
JavaScript

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