188 lines
5.2 KiB
JavaScript
188 lines
5.2 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
/** @param {string} name @param {string} fallback */
|
|
function argumentValue(name, fallback) {
|
|
const index = process.argv.indexOf(name);
|
|
return index >= 0 && process.argv[index + 1]
|
|
? process.argv[index + 1]
|
|
: fallback;
|
|
}
|
|
|
|
const policyPath = argumentValue(
|
|
"--policy",
|
|
"config/security/secret-scan-policy.json",
|
|
);
|
|
const artifactPath = argumentValue(
|
|
"--artifact",
|
|
"artifacts/security/scan.sarif",
|
|
);
|
|
const policy = JSON.parse(await readFile(policyPath, "utf8"));
|
|
const findings =
|
|
/** @type {Array<{
|
|
* ruleId: string,
|
|
* file: string,
|
|
* line: number,
|
|
* fingerprint: string
|
|
* }>} */ ([]);
|
|
const policyFailures = [];
|
|
const patterns = [
|
|
{
|
|
id: "private-key",
|
|
expression: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/g,
|
|
},
|
|
{ id: "aws-access-key", expression: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
{ id: "github-token", expression: /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g },
|
|
{
|
|
id: "assigned-secret",
|
|
expression:
|
|
/\b(?:client_secret|password|private_key)\s*[:=]\s*["'][^"'${}]{12,}["']/gi,
|
|
},
|
|
];
|
|
|
|
/** @param {string} target @returns {Promise<string[]>} */
|
|
async function filesWithin(target) {
|
|
try {
|
|
const metadata = await stat(target);
|
|
if (metadata.isFile()) return [target];
|
|
const entries = await readdir(target, { withFileTypes: true });
|
|
const nested = /** @type {string[][]} */ (await Promise.all(
|
|
entries.map((entry) => {
|
|
const child = path.join(target, entry.name);
|
|
return entry.isDirectory() ? filesWithin(child) : [child];
|
|
}),
|
|
));
|
|
return nested.flat();
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
const excluded = new Set(
|
|
/** @type {string[]} */ (policy.excludedPaths ?? []).map((entry) =>
|
|
entry.replaceAll("\\", "/"),
|
|
),
|
|
);
|
|
const allowlist =
|
|
/** @type {Array<{
|
|
* path: string,
|
|
* ruleId: string,
|
|
* owner: string,
|
|
* reason: string,
|
|
* expiresAt: string
|
|
* }>} */ (policy.allowlist ?? []);
|
|
for (const entry of allowlist) {
|
|
const expiry = Date.parse(entry.expiresAt);
|
|
if (
|
|
!entry.path.startsWith("tests/") ||
|
|
!entry.owner?.trim() ||
|
|
!entry.reason?.trim() ||
|
|
!Number.isFinite(expiry) ||
|
|
expiry <= Date.now()
|
|
) {
|
|
policyFailures.push(
|
|
`invalid or expired secret allowlist entry: ${entry.path}:${entry.ruleId}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const roots = [
|
|
...(/** @type {string[]} */ (policy.trackedRoots ?? [])),
|
|
...(/** @type {string[]} */ (policy.generatedRoots ?? [])),
|
|
];
|
|
const scanFiles = (
|
|
await Promise.all(roots.map((root) => filesWithin(root)))
|
|
).flat();
|
|
for (const scanFile of [...new Set(scanFiles)].sort()) {
|
|
const normalized = scanFile.replaceAll("\\", "/");
|
|
if (
|
|
[...excluded].some(
|
|
(entry) => normalized === entry || normalized.startsWith(`${entry}/`),
|
|
) ||
|
|
/\.(?:png|jpe?g|gif|webp|woff2?|zip|gz|sarif)$/i.test(normalized)
|
|
) {
|
|
continue;
|
|
}
|
|
let content;
|
|
try {
|
|
content = await readFile(scanFile, "utf8");
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const pattern of patterns) {
|
|
pattern.expression.lastIndex = 0;
|
|
for (const match of content.matchAll(pattern.expression)) {
|
|
const isAllowed = allowlist.some(
|
|
(entry) =>
|
|
entry.path === normalized &&
|
|
entry.ruleId === pattern.id &&
|
|
Date.parse(entry.expiresAt) > Date.now(),
|
|
);
|
|
if (isAllowed) continue;
|
|
const prefix = content.slice(0, match.index);
|
|
findings.push({
|
|
ruleId: pattern.id,
|
|
file: normalized,
|
|
line: prefix.split(/\r?\n/).length,
|
|
fingerprint: createHash("sha256")
|
|
.update(`${pattern.id}:${normalized}:${String(match.index)}`)
|
|
.digest("hex"),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const sarif = {
|
|
version: "2.1.0",
|
|
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
runs: [
|
|
{
|
|
tool: {
|
|
driver: {
|
|
name: "ca-frontend-secret-scan",
|
|
rules: patterns.map((pattern) => ({
|
|
id: pattern.id,
|
|
shortDescription: { text: "Potential credential material" },
|
|
})),
|
|
},
|
|
},
|
|
results: [
|
|
...findings.map((finding) => ({
|
|
ruleId: finding.ruleId,
|
|
message: {
|
|
text: "Potential secret material must be removed.",
|
|
},
|
|
partialFingerprints: {
|
|
primaryLocationLineHash: finding.fingerprint,
|
|
},
|
|
locations: [
|
|
{
|
|
physicalLocation: {
|
|
artifactLocation: { uri: finding.file },
|
|
region: { startLine: finding.line },
|
|
},
|
|
},
|
|
],
|
|
})),
|
|
...policyFailures.map((failure) => ({
|
|
ruleId: "invalid-allowlist",
|
|
message: { text: failure },
|
|
})),
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
await mkdir(path.dirname(artifactPath), { recursive: true });
|
|
await writeFile(artifactPath, `${JSON.stringify(sarif, null, 2)}\n`);
|
|
if (findings.length > 0 || policyFailures.length > 0) {
|
|
process.stderr.write(
|
|
`Security scan found ${findings.length + policyFailures.length} blocking result(s).\n`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
process.stdout.write(
|
|
`Tracked source, config, built asset and artifact secret scan: PASS (${scanFiles.length} files)\n`,
|
|
);
|