Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,295 @@
|
||||
#!/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 */
|
||||
Reference in new issue
Block a user