254 lines
6.1 KiB
JavaScript
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");
|