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>
270 lines
6.8 KiB
JavaScript
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");
|
|
}
|