feat: 기능 추가 과정중
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
type SecretFinding = Readonly<{
|
||||
ruleId: string;
|
||||
file: string;
|
||||
line: number;
|
||||
fingerprint: string;
|
||||
}>;
|
||||
type AllowlistEntry = Readonly<{
|
||||
path: string;
|
||||
ruleId: string;
|
||||
owner: string;
|
||||
reason: string;
|
||||
expiresAt: string;
|
||||
}>;
|
||||
type SecretPolicy = Readonly<{
|
||||
excludedPaths: readonly string[];
|
||||
trackedRoots: readonly string[];
|
||||
generatedRoots: readonly string[];
|
||||
allowlist: readonly AllowlistEntry[];
|
||||
}>;
|
||||
|
||||
function argumentValue(name: string, fallback: string): string {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 && process.argv[index + 1]
|
||||
? process.argv[index + 1]
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function strings(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function parsePolicy(value: unknown): SecretPolicy {
|
||||
const document = isRecord(value) ? value : {};
|
||||
const allowlist = Array.isArray(document.allowlist)
|
||||
? document.allowlist.map((rawEntry) => {
|
||||
const entry = isRecord(rawEntry) ? rawEntry : {};
|
||||
return {
|
||||
path: typeof entry.path === "string" ? entry.path : "",
|
||||
ruleId: typeof entry.ruleId === "string" ? entry.ruleId : "",
|
||||
owner: typeof entry.owner === "string" ? entry.owner : "",
|
||||
reason: typeof entry.reason === "string" ? entry.reason : "",
|
||||
expiresAt:
|
||||
typeof entry.expiresAt === "string" ? entry.expiresAt : "",
|
||||
};
|
||||
})
|
||||
: [];
|
||||
return Object.freeze({
|
||||
excludedPaths: Object.freeze(strings(document.excludedPaths)),
|
||||
trackedRoots: Object.freeze(strings(document.trackedRoots)),
|
||||
generatedRoots: Object.freeze(strings(document.generatedRoots)),
|
||||
allowlist: Object.freeze(allowlist),
|
||||
});
|
||||
}
|
||||
|
||||
const policyPath = argumentValue(
|
||||
"--policy",
|
||||
"config/security/secret-scan-policy.json",
|
||||
);
|
||||
const artifactPath = argumentValue(
|
||||
"--artifact",
|
||||
"artifacts/security/scan.sarif",
|
||||
);
|
||||
const rawPolicy: unknown = JSON.parse(await readFile(policyPath, "utf8"));
|
||||
const policy = parsePolicy(rawPolicy);
|
||||
const findings: SecretFinding[] = [];
|
||||
const policyFailures: string[] = [];
|
||||
const patterns: readonly Readonly<{ id: string; expression: RegExp }>[] = [
|
||||
{
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
async function filesWithin(target: string): Promise<string[]> {
|
||||
try {
|
||||
const metadata = await stat(target);
|
||||
if (metadata.isFile()) return [target];
|
||||
const entries = await readdir(target, { withFileTypes: true });
|
||||
const nested: 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(
|
||||
policy.excludedPaths.map((entry) => entry.replaceAll("\\", "/")),
|
||||
);
|
||||
const allowlist = 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 = [...policy.trackedRoots, ...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: string;
|
||||
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 matchIndex = match.index ?? 0;
|
||||
const prefix = content.slice(0, matchIndex);
|
||||
findings.push({
|
||||
ruleId: pattern.id,
|
||||
file: normalized,
|
||||
line: prefix.split(/\r?\n/).length,
|
||||
fingerprint: createHash("sha256")
|
||||
.update(`${pattern.id}:${normalized}:${String(matchIndex)}`)
|
||||
.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`,
|
||||
);
|
||||
Reference in New Issue
Block a user