singularity-forge/scripts/secret-scan.mjs
2026-05-05 14:31:16 +02:00

254 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");