Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/verify-secrets.mjs
T

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 */