singularity-forge/scripts/secret-scan.mjs
Mikael Hugo fa9baf71d5 feat(secret-scan): SF_SECURITY_FAST contract for the regex-only fast path
Codifies AC4 of sf-mp4w2dij-xm6cwj: the regex-only path is the
today-default fast mode. SF_SECURITY_FAST=1 is the explicit opt-in for
callers that want to assert "regex-only, no LLM escalation, sub-100ms"
regardless of any future tiered reviewer landing in the script.

Today the env var changes only the trailing status line so operators
can verify the contract is observable. When the LLM-backed review hook
(AC1) lands, the absence of SF_SECURITY_FAST becomes the trigger for
escalation; setting it=1 keeps offline / pre-commit callers on the
fast path. Locked in by tests in both the .sh and .mjs scanners.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 07:57:02 +02:00

270 lines
6.8 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";
// Fast-mode contract (sf-mp4w2dij-xm6cwj AC4): the regex-only path is the
// today-default fast path. SF_SECURITY_FAST=1 is the explicit opt-in for
// callers that want to assert "regex-only, no LLM escalation, sub-100ms"
// regardless of any future tiered reviewer landing in this script. When the
// LLM-backed review hook (AC1) lands, the absence of this env var becomes
// the trigger for escalation; setting it=1 keeps the offline / pre-commit
// fast path. Today the variable changes only the trailing status line so
// operators can verify the contract is observable.
const FAST_MODE = process.env.SF_SECURITY_FAST === "1";
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);
}
if (FAST_MODE) {
process.stdout.write(
"secret-scan: fast mode (regex-only, no LLM escalation) ✓\n",
);
} else {
process.stdout.write("secret-scan: no secrets detected ✓\n");
}