296 lines
7.9 KiB
JavaScript
296 lines
7.9 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Secret-scanning CI gate.
|
|
*
|
|
* Walks `apps/`, `packages/`, `scripts/`, `prisma/`, `docs/` (recursively) and
|
|
* the repository root (top-level files only) looking for hardcoded secrets:
|
|
* GitHub PATs, GitLab PATs, AWS access keys, Stripe live keys, Slack tokens,
|
|
* private-key PEM headers, JWT-shaped tokens, and high-entropy assignment
|
|
* literals on lines that look like secret declarations.
|
|
*
|
|
* Findings on `.env.example` variants or test/fixture/mock/stub paths are
|
|
* recorded as "reviewed" rather than failures. Lines that contain obvious
|
|
* placeholder markers (`placeholder`, `example`, `your-`, `<...>`, `${...}`,
|
|
* `xxxxx`, `dummy`, `REDACTED`, ...) are skipped entirely.
|
|
*
|
|
* Exit 1 on any real (non-reviewed) finding, 0 otherwise.
|
|
*/
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import url from "node:url";
|
|
|
|
const RECURSIVE_ROOTS = ["apps", "packages", "scripts", "prisma", "docs"];
|
|
|
|
export const SKIP_DIR_NAMES = new Set([
|
|
"node_modules",
|
|
"dist",
|
|
"build",
|
|
"coverage",
|
|
".react-router",
|
|
".git",
|
|
".cache",
|
|
"generated",
|
|
".next",
|
|
".turbo",
|
|
]);
|
|
|
|
const SKIP_FILE_SUFFIXES = [
|
|
".lock",
|
|
"-lock.json",
|
|
".png",
|
|
".jpg",
|
|
".jpeg",
|
|
".svg",
|
|
".ico",
|
|
".webp",
|
|
".pdf",
|
|
];
|
|
|
|
const SKIP_FILE_NAMES = new Set(["pnpm-lock.yaml"]);
|
|
|
|
// Allowlisted env-example filenames whose findings are logged as reviewed.
|
|
const ALLOWLISTED_FILENAMES = new Set([
|
|
".env.example",
|
|
".env.local.example",
|
|
".env.production.example",
|
|
".env.fly.example",
|
|
".env.test",
|
|
]);
|
|
|
|
// Path segments (case-insensitive substring) that mark a file as a
|
|
// test/fixture/mock surface; findings inside are reviewed, not failures.
|
|
const ALLOWLISTED_PATH_SEGMENTS = ["test", "spec", "fixture", "mock", "stub"];
|
|
|
|
// Self-reference: this scanner file describes the patterns it scans for, so
|
|
// matches against its own source must be treated as reviewed.
|
|
const SELF_FILENAMES = new Set([
|
|
"verify-secrets.mjs",
|
|
"verify-secrets.test.mjs",
|
|
]);
|
|
|
|
// Placeholder markers that disqualify a line from being a real secret.
|
|
const PLACEHOLDER_MARKERS = [
|
|
"placeholder",
|
|
"example",
|
|
"your-",
|
|
"your_",
|
|
"replace-with",
|
|
"replace_with",
|
|
"xxxxx",
|
|
"dummy",
|
|
"redacted",
|
|
"changeme",
|
|
"change-me",
|
|
"change_me",
|
|
"todo",
|
|
"fixme",
|
|
];
|
|
|
|
export function lineHasPlaceholder(line) {
|
|
const lower = line.toLowerCase();
|
|
for (const marker of PLACEHOLDER_MARKERS) {
|
|
if (lower.includes(marker)) return true;
|
|
}
|
|
if (/<[^>]+>/.test(line)) return true;
|
|
if (/\{[^}]+\}/.test(line)) return true;
|
|
if (line.includes("${")) return true;
|
|
return false;
|
|
}
|
|
|
|
export const PATTERNS = [
|
|
{ id: "github-pat", regex: /\bghp_[A-Za-z0-9_]{20,}\b/g },
|
|
{ id: "github-fine-grained-pat", regex: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g },
|
|
{ id: "gitlab-pat", regex: /\bglpat-[A-Za-z0-9_-]{20,}\b/g },
|
|
{ id: "aws-access-key-id", regex: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
{ id: "stripe-secret-live", regex: /\bsk_live_[A-Za-z0-9]{20,}\b/g },
|
|
{
|
|
id: "stripe-webhook-live",
|
|
regex: /\bwhsec_[A-Za-z0-9]{20,}\b/g,
|
|
requiresLineContains: "live",
|
|
},
|
|
{ id: "slack-bot-token", regex: /\bxoxb-\d+-\d+-\d+-[A-Za-z0-9]+\b/g },
|
|
{
|
|
id: "private-key-header",
|
|
regex: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g,
|
|
},
|
|
{
|
|
id: "jwt-like",
|
|
regex:
|
|
/\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
|
|
},
|
|
{
|
|
id: "generic-high-entropy-assignment",
|
|
regex:
|
|
/\b(?:password|secret|token|api_?key|webhook_secret)\s*[:=]\s*['"][A-Za-z0-9+/=_-]{32,}['"]/gi,
|
|
},
|
|
];
|
|
|
|
export function isAllowlistedPath(absPath) {
|
|
const basename = path.basename(absPath);
|
|
if (SELF_FILENAMES.has(basename)) return true;
|
|
if (ALLOWLISTED_FILENAMES.has(basename)) return true;
|
|
const lower = absPath.toLowerCase();
|
|
for (const seg of ALLOWLISTED_PATH_SEGMENTS) {
|
|
if (lower.includes(seg)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function scanText(text, { filename = "" } = {}) {
|
|
const findings = [];
|
|
const lines = text.split(/\r?\n/);
|
|
for (let i = 0; i < lines.length; i += 1) {
|
|
const line = lines[i];
|
|
if (lineHasPlaceholder(line)) continue;
|
|
for (const pattern of PATTERNS) {
|
|
pattern.regex.lastIndex = 0;
|
|
for (const match of line.matchAll(pattern.regex)) {
|
|
if (
|
|
pattern.requiresLineContains &&
|
|
!line.toLowerCase().includes(pattern.requiresLineContains)
|
|
) {
|
|
continue;
|
|
}
|
|
findings.push({
|
|
file: filename,
|
|
line: i + 1,
|
|
patternId: pattern.id,
|
|
match: match[0],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return findings;
|
|
}
|
|
|
|
function shouldSkipFile(absPath) {
|
|
const basename = path.basename(absPath);
|
|
if (SKIP_FILE_NAMES.has(basename)) return true;
|
|
const lower = basename.toLowerCase();
|
|
for (const suffix of SKIP_FILE_SUFFIXES) {
|
|
if (lower.endsWith(suffix)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function listFilesRecursive(dir) {
|
|
if (!fs.existsSync(dir)) return [];
|
|
const out = [];
|
|
const stack = [dir];
|
|
while (stack.length > 0) {
|
|
const current = stack.pop();
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory()) {
|
|
if (SKIP_DIR_NAMES.has(entry.name)) continue;
|
|
stack.push(path.join(current, entry.name));
|
|
} else if (entry.isFile()) {
|
|
const abs = path.join(current, entry.name);
|
|
if (shouldSkipFile(abs)) continue;
|
|
out.push(abs);
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function listRootTopLevelFiles(root) {
|
|
if (!fs.existsSync(root)) return [];
|
|
const out = [];
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
} catch {
|
|
return [];
|
|
}
|
|
for (const entry of entries) {
|
|
if (!entry.isFile()) continue;
|
|
const abs = path.join(root, entry.name);
|
|
if (shouldSkipFile(abs)) continue;
|
|
out.push(abs);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function collectFiles(root) {
|
|
const files = [];
|
|
for (const sub of RECURSIVE_ROOTS) {
|
|
files.push(...listFilesRecursive(path.join(root, sub)));
|
|
}
|
|
files.push(...listRootTopLevelFiles(root));
|
|
return files;
|
|
}
|
|
|
|
function truncate(value, max = 80) {
|
|
if (value.length <= max) return value;
|
|
return `${value.slice(0, max)}...`;
|
|
}
|
|
|
|
export function evaluate(root = process.cwd()) {
|
|
const files = collectFiles(root);
|
|
const real = [];
|
|
const reviewed = [];
|
|
for (const file of files) {
|
|
let content;
|
|
try {
|
|
content = fs.readFileSync(file, "utf8");
|
|
} catch {
|
|
continue;
|
|
}
|
|
const findings = scanText(content, { filename: file });
|
|
if (findings.length === 0) continue;
|
|
const allowlisted = isAllowlistedPath(file);
|
|
for (const finding of findings) {
|
|
if (allowlisted) {
|
|
reviewed.push(finding);
|
|
} else {
|
|
real.push(finding);
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
scanned: files.length,
|
|
patterns: PATTERNS.length,
|
|
real,
|
|
reviewed,
|
|
};
|
|
}
|
|
|
|
function formatFinding(finding, rootRel) {
|
|
const rel = path.relative(rootRel, finding.file) || finding.file;
|
|
return `${rel}:${finding.line}: ${finding.patternId} matched: ${truncate(finding.match)}`;
|
|
}
|
|
|
|
const isMain =
|
|
import.meta.url === url.pathToFileURL(process.argv[1] ?? "").href;
|
|
|
|
/* eslint-disable no-console -- CLI reporter writes to stdout/stderr. */
|
|
if (isMain) {
|
|
const root = process.cwd();
|
|
const result = evaluate(root);
|
|
if (result.real.length === 0) {
|
|
console.log("PASS no secret-shaped strings found");
|
|
} else {
|
|
for (const finding of result.real) {
|
|
console.error(`FAIL ${formatFinding(finding, root)}`);
|
|
}
|
|
}
|
|
if (result.reviewed.length > 0) {
|
|
for (const finding of result.reviewed) {
|
|
console.log(`reviewed ${formatFinding(finding, root)}`);
|
|
}
|
|
}
|
|
console.log(
|
|
`\nSummary: scanned files: ${result.scanned}, patterns: ${result.patterns}, real: ${result.real.length}, reviewed: ${result.reviewed.length}`,
|
|
);
|
|
if (result.real.length > 0) {
|
|
process.exit(1);
|
|
}
|
|
}
|
|
/* eslint-enable no-console */
|