fix: recompute local promotion evidence

This commit is contained in:
DongHyeonka
2026-08-02 07:02:39 +09:00
parent 92e5cace5c
commit 1b4b0c2821
8 changed files with 837 additions and 253 deletions
@@ -1,5 +1,5 @@
import { createHash, generateKeyPairSync, sign } from "node:crypto";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -9,12 +9,35 @@ import {
createReleaseCandidateManifest,
RELEASE_CANDIDATE_EVIDENCE_PATHS,
RELEASE_CANDIDATE_MANIFEST_PATH,
releaseCandidateManifestSchema,
} from "./lib/release-candidate.ts";
const fixtureRoot = await mkdtemp(
path.join(tmpdir(), "supply-chain-provider-fixture-"),
);
try {
const repositoryRoot = process.cwd();
const actualCandidate = releaseCandidateManifestSchema.parse(
JSON.parse(
await readFile(
path.join(repositoryRoot, RELEASE_CANDIDATE_MANIFEST_PATH),
"utf8",
),
) as unknown,
);
const actualProviderEnvironment = absoluteProviderEnvironment(
fixtureRoot,
await writeProviderEnvironment(
fixtureRoot,
"actual",
actualCandidate.distSha256,
actualCandidate.lockfileSha256,
),
);
const actualDefaultVerifier = await verifyPromotionInputs({
environment: actualProviderEnvironment,
});
const rawLockfile = "lockfileVersion: '9.0'\n";
const lockfileSha256 = createHash("sha256")
.update(rawLockfile)
@@ -86,6 +109,7 @@ try {
});
const passed =
actualDefaultVerifier.status === "PASS" &&
fixtures.validImmutable.status === "PASS" &&
fixtures.absent.status === "FAIL_UNVERIFIED" &&
fixtures.wrongDigest.status === "FAIL_UNVERIFIED" &&
@@ -96,6 +120,10 @@ try {
`${JSON.stringify(
{
schemaVersion: 1,
actualDefaultVerifier: {
status: actualDefaultVerifier.status,
failures: actualDefaultVerifier.failures,
},
fixtures: Object.fromEntries(
Object.entries(fixtures).map(([name, result]) => [
name,
@@ -118,13 +146,30 @@ try {
process.exitCode = 1;
} else {
process.stdout.write(
"Supply-chain provider fixtures: only the valid immutable fixture PASS\n",
"Supply-chain provider fixtures: actual default verifier and valid immutable fixture PASS\n",
);
}
} finally {
await rm(fixtureRoot, { recursive: true, force: true });
}
function absoluteProviderEnvironment(
repositoryRoot: string,
environment: NodeJS.ProcessEnv,
): NodeJS.ProcessEnv {
const absolute = { ...environment };
for (const key of [
"VULNERABILITY_REPORT_PATH",
"PROVENANCE_ATTESTATION_PATH",
"VULNERABILITY_PUBLIC_KEY_PATH",
"PROVENANCE_PUBLIC_KEY_PATH",
] as const) {
const value = absolute[key];
if (value) absolute[key] = path.join(repositoryRoot, value);
}
return absolute;
}
async function writeProviderEnvironment(
repositoryRoot: string,
name: string,
+21 -57
View File
@@ -18,16 +18,17 @@ import {
vulnerabilityReportArtifactSchema,
} from "./contracts/release-artifacts.ts";
import {
diffDependencyInventories,
flattenPnpmDependencyTree,
isValidSha512Integrity,
parsePnpmLockfilePackages,
supplyChainDigest,
validateDependencyReview,
validateLicensePolicy,
verifySupplyChainCoherence,
type DependencyInventoryDiff,
} from "./lib/supply-chain.ts";
import {
distChecksumsText,
recomputeDependencyEvidence,
recomputeLicenseEvidence,
} from "./lib/local-policy-evidence.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
import { digestReleaseInputFiles } from "./lib/release-input-evidence.ts";
import {
@@ -159,7 +160,6 @@ const inventory = await buildDependencyInventory();
const licensePolicy = JSON.parse(
await readFile("config/security/dependency-policy.json", "utf8"),
);
const licenseResult = validateLicensePolicy(inventory, licensePolicy);
const baseline = await optionalJson(
"config/security/dependency-baseline.json",
@@ -174,39 +174,17 @@ const dependencyEvidence = JSON.parse(
),
);
const skipsBaseline = process.argv.includes("--no-baseline");
const baselineFailures: string[] = [];
let dependencyDiff: DependencyInventoryDiff = Object.freeze({
added: Object.freeze([]),
removed: Object.freeze([]),
changed: Object.freeze([]),
upgrades: Object.freeze([]),
const dependencyPolicy = recomputeDependencyEvidence({
inventory,
baseline,
baselineApproval,
dependencyChangeEvidence: dependencyEvidence,
skipBaseline: skipsBaseline,
});
let reviewResult: ReturnType<typeof validateDependencyReview> = Object.freeze({
passed: skipsBaseline,
highRisk: Object.freeze([]),
failures: Object.freeze(
skipsBaseline ? [] : ["dependency baseline unavailable"],
),
const licenseEvidence = recomputeLicenseEvidence({
inventory,
policy: licensePolicy,
});
if (baseline && baselineApproval) {
const actualBaselineDigest = supplyChainDigest(baseline);
if (
baselineApproval.schemaVersion !== 1 ||
baselineApproval.snapshotDigest !== actualBaselineDigest ||
typeof baselineApproval.owner !== "string" ||
!baselineApproval.owner
) {
baselineFailures.push("dependency baseline approval digest mismatch");
}
dependencyDiff = diffDependencyInventories(baseline, inventory);
reviewResult = validateDependencyReview(
dependencyDiff,
inventory,
dependencyEvidence,
);
} else if (!skipsBaseline) {
baselineFailures.push("dependency baseline and approval are required");
}
const vulnerabilityReport = {
schemaVersion: 1,
@@ -315,9 +293,8 @@ const coherence = verifySupplyChainCoherence(
);
const localFailures = [
...licenseResult.failures,
...baselineFailures,
...reviewResult.failures,
...licenseEvidence.failures,
...dependencyPolicy.failures,
...coherence.failures,
];
const localPassed = localFailures.length === 0;
@@ -329,8 +306,8 @@ const verification = {
sourceSetSha256,
distSha256: distDigest,
sbomSha256: supplyChainDigest(sbom),
dependencyDiff,
highRiskReview: reviewResult.highRisk,
dependencyDiff: dependencyPolicy.dependencyDiff,
highRiskReview: dependencyPolicy.highRisk,
vulnerabilityStatus: vulnerabilityReport.status,
provenanceAttestationStatus: "FAIL_UNVERIFIED",
failures: localFailures,
@@ -349,21 +326,8 @@ const bundleReport = {
},
outputs,
};
const dependencyDiffReport = {
schemaVersion: 2,
baselineDigest: baseline ? supplyChainDigest(baseline) : null,
currentDigest: supplyChainDigest(inventory),
...dependencyDiff,
highRisk: reviewResult.highRisk,
reviewFailures: reviewResult.failures,
};
const licenseReport = {
schemaVersion: 1,
status: licenseResult.passed ? "PASS" : "FAIL",
dependencyCount: inventory.dependencyCount,
results: licenseResult.results,
failures: licenseResult.failures,
};
const dependencyDiffReport = dependencyPolicy.report;
const licenseReport = licenseEvidence.report;
await mkdir("artifacts/performance", { recursive: true });
await mkdir("artifacts/release", { recursive: true });
@@ -390,7 +354,7 @@ await writeValidatedJsonArtifact({
});
await writeFile(
"artifacts/release/checksums.txt",
`${outputs.map((output) => `${output.sha256} ${output.path}`).join("\n")}\n`,
distChecksumsText(outputs),
);
await writeValidatedJsonArtifact({
path: "artifacts/security/dependency-diff.json",
+150
View File
@@ -0,0 +1,150 @@
import {
dependencyDiffArtifactSchema,
dependencyInventoryArtifactSchema,
licenseReportArtifactSchema,
} from "../contracts/release-artifacts.ts";
import type { DistOutput } from "./release-candidate.ts";
import {
diffDependencyInventories,
supplyChainDigest,
validateDependencyReview,
validateLicensePolicy,
} from "./supply-chain.ts";
type Document = Record<string, unknown>;
export function recomputeDependencyEvidence(input: Readonly<{
inventory: unknown;
baseline: unknown;
baselineApproval: unknown;
dependencyChangeEvidence: unknown;
skipBaseline?: boolean;
}>) {
const inventory = dependencyInventoryArtifactSchema.parse(input.inventory);
const baseline = asDocument(input.baseline);
const approval = asDocument(input.baselineApproval);
const dependencyChangeEvidence = asDocument(input.dependencyChangeEvidence);
const failures: string[] = [];
const skipBaseline = input.skipBaseline === true;
let diff: ReturnType<typeof diffDependencyInventories> = Object.freeze({
added: Object.freeze([]),
removed: Object.freeze([]),
changed: Object.freeze([]),
upgrades: Object.freeze([]),
});
let review: ReturnType<typeof validateDependencyReview> = Object.freeze({
passed: skipBaseline,
highRisk: Object.freeze([]),
failures: Object.freeze(
skipBaseline ? [] : ["dependency baseline unavailable"],
),
});
const baselineDigest = baseline ? supplyChainDigest(baseline) : null;
if (baseline && approval) {
diff = diffDependencyInventories(baseline, inventory);
review = validateDependencyReview(
diff,
inventory,
dependencyChangeEvidence ?? {},
);
if (
approval.schemaVersion !== 1 ||
approval.snapshotDigest !== baselineDigest ||
typeof approval.owner !== "string" ||
approval.owner.trim().length === 0
) {
failures.push("dependency baseline approval digest mismatch");
}
} else if (!skipBaseline) {
failures.push("dependency baseline and approval are required");
}
failures.push(...review.failures);
const report = dependencyDiffArtifactSchema.parse({
schemaVersion: 2,
baselineDigest,
currentDigest: supplyChainDigest(inventory),
...diff,
highRisk: review.highRisk,
reviewFailures: review.failures,
});
return Object.freeze({
report,
dependencyDiff: diff,
highRisk: review.highRisk,
failures: Object.freeze(failures),
});
}
export function compareStoredDependencyEvidence(
recomputed: ReturnType<typeof recomputeDependencyEvidence>,
stored: unknown,
): string[] {
const parsed = dependencyDiffArtifactSchema.safeParse(stored);
if (
!parsed.success ||
supplyChainDigest(parsed.data) !== supplyChainDigest(recomputed.report)
) {
return ["stored dependency diff does not match recomputed policy evidence"];
}
return [...recomputed.failures];
}
export function recomputeLicenseEvidence(input: Readonly<{
inventory: unknown;
policy: unknown;
}>) {
const inventory = dependencyInventoryArtifactSchema.parse(input.inventory);
const policy = asDocument(input.policy) ?? {};
const result = validateLicensePolicy(inventory, policy);
const report = licenseReportArtifactSchema.parse({
schemaVersion: 1,
status: result.passed ? "PASS" : "FAIL",
dependencyCount: inventory.dependencyCount,
results: result.results,
failures: result.failures,
});
return Object.freeze({
report,
failures: result.failures,
});
}
export function compareStoredLicenseEvidence(
recomputed: ReturnType<typeof recomputeLicenseEvidence>,
stored: unknown,
): string[] {
const parsed = licenseReportArtifactSchema.safeParse(stored);
if (
!parsed.success ||
supplyChainDigest(parsed.data) !== supplyChainDigest(recomputed.report)
) {
return ["stored license report does not match recomputed policy evidence"];
}
return [...recomputed.failures];
}
export function distChecksumsText(outputs: readonly DistOutput[]): string {
return `${[...outputs]
.sort((left, right) => left.path.localeCompare(right.path))
.map((output) => `${output.sha256} ${output.path}`)
.join("\n")}\n`;
}
export function verifyStoredDistChecksums(
outputs: readonly DistOutput[],
stored: string,
): string[] {
return stored === distChecksumsText(outputs)
? []
: ["stored dist checksums do not match current outputs"];
}
function asDocument(value: unknown): Document | null {
return value !== null &&
typeof value === "object" &&
!Array.isArray(value)
? (value as Document)
: null;
}
+151 -31
View File
@@ -32,10 +32,21 @@ import type { ReleaseCandidateManifest } from "./release-candidate.ts";
import { collectDistOutputs, distSha256 } from "./release-candidate.ts";
import { verifyReleaseRuntimeCoherence } from "./release-runtime-coherence.ts";
import { digestReleaseInputFiles } from "./release-input-evidence.ts";
import {
compareStoredDependencyEvidence,
compareStoredLicenseEvidence,
recomputeDependencyEvidence,
recomputeLicenseEvidence,
verifyStoredDistChecksums,
} from "./local-policy-evidence.ts";
import {
buildRepositoryFileInventory,
parseRepositoryFileInventoryPolicy,
} from "./repository-file-inventory.ts";
import {
evaluateRepositorySecretScan,
verifyStoredSecretScan,
} from "./secret-scan-evaluator.ts";
import {
isValidSha512Integrity,
parsePnpmLockfilePackages,
@@ -137,15 +148,15 @@ export async function verifyLocalSupplyChainEvidence(
}
const sbomSha256 = sbom ? supplyChainDigest(sbom) : "0".repeat(64);
let coherenceFailures: readonly string[] = Object.freeze([]);
if (inventory && sbom && provenance) {
failures.push(
...verifySupplyChainCoherence(
sbom,
inventory,
provenance,
distDigest,
).failures,
);
coherenceFailures = verifySupplyChainCoherence(
sbom,
inventory,
provenance,
distDigest,
).failures;
failures.push(...coherenceFailures);
}
if (
!inventory ||
@@ -193,6 +204,53 @@ export async function verifyLocalSupplyChainEvidence(
failures.push("release source inventory is unavailable or unreadable");
}
}
if (inventory && verification) {
try {
const dependencyPolicy = recomputeDependencyEvidence({
inventory,
baseline: await optionalReadJson(
repositoryRoot,
"config/security/dependency-baseline.json",
),
baselineApproval: await optionalReadJson(
repositoryRoot,
"config/security/dependency-baseline.approval.json",
),
dependencyChangeEvidence: await readJson(
repositoryRoot,
"config/security/dependency-change-evidence.json",
),
});
const licensePolicy = recomputeLicenseEvidence({
inventory,
policy: await readJson(
repositoryRoot,
"config/security/dependency-policy.json",
),
});
const expectedFailures = [
...licensePolicy.failures,
...dependencyPolicy.failures,
...coherenceFailures,
];
if (
supplyChainDigest(verification.dependencyDiff) !==
supplyChainDigest(dependencyPolicy.dependencyDiff) ||
supplyChainDigest(verification.highRiskReview) !==
supplyChainDigest(dependencyPolicy.highRisk) ||
supplyChainDigest(verification.failures) !==
supplyChainDigest(expectedFailures) ||
verification.localStatus !==
(expectedFailures.length === 0 ? "PASS" : "FAIL")
) {
failures.push(
"supply-chain verification policy fields do not match recomputed evidence",
);
}
} catch {
failures.push("supply-chain verification policy inputs are invalid");
}
}
const lockRows = parsePnpmLockfilePackages(lockfileText);
const inventoryRows = inventory?.dependencies ?? [];
@@ -366,6 +424,13 @@ async function validateSupportingArtifacts(
repositoryRoot: string,
failures: string[],
): Promise<void> {
let actualOutputs: Awaited<ReturnType<typeof collectDistOutputs>> | null =
null;
try {
actualOutputs = await collectDistOutputs(repositoryRoot);
} catch {
failures.push("supporting evidence dist inputs are unreadable");
}
const bundle = await parseArtifact(
repositoryRoot,
"artifacts/performance/bundle.json",
@@ -373,16 +438,35 @@ async function validateSupportingArtifacts(
"bundle report",
failures,
);
if (bundle) {
if (
bundle &&
actualOutputs &&
JSON.stringify(bundle.outputs) !== JSON.stringify(actualOutputs)
) {
failures.push("bundle report does not describe current dist bytes");
}
if (actualOutputs) {
try {
const actual = await collectDistOutputs(repositoryRoot);
if (JSON.stringify(bundle.outputs) !== JSON.stringify(actual)) {
failures.push("bundle report does not describe current dist bytes");
}
failures.push(
...verifyStoredDistChecksums(
actualOutputs,
await readFile(
path.join(repositoryRoot, "artifacts/release/checksums.txt"),
"utf8",
),
),
);
} catch {
failures.push("bundle report dist inputs are unreadable");
failures.push("stored dist checksums are missing or unreadable");
}
}
const inventory = await parseArtifact(
repositoryRoot,
"artifacts/release/dependency-inventory.json",
dependencyInventoryArtifactSchema,
"dependency inventory",
failures,
);
const dependencyDiff = await parseArtifact(
repositoryRoot,
"artifacts/security/dependency-diff.json",
@@ -390,8 +474,29 @@ async function validateSupportingArtifacts(
"dependency diff",
failures,
);
if (dependencyDiff && dependencyDiff.reviewFailures.length > 0) {
failures.push("dependency review evidence is not PASS");
if (inventory && dependencyDiff) {
try {
const recomputed = recomputeDependencyEvidence({
inventory,
baseline: await optionalReadJson(
repositoryRoot,
"config/security/dependency-baseline.json",
),
baselineApproval: await optionalReadJson(
repositoryRoot,
"config/security/dependency-baseline.approval.json",
),
dependencyChangeEvidence: await readJson(
repositoryRoot,
"config/security/dependency-change-evidence.json",
),
});
failures.push(
...compareStoredDependencyEvidence(recomputed, dependencyDiff),
);
} catch {
failures.push("dependency policy evidence is missing or invalid");
}
}
const license = await parseArtifact(
repositoryRoot,
@@ -400,8 +505,19 @@ async function validateSupportingArtifacts(
"license report",
failures,
);
if (license && (license.status !== "PASS" || license.failures.length > 0)) {
failures.push("license report is not PASS");
if (inventory && license) {
try {
const recomputed = recomputeLicenseEvidence({
inventory,
policy: await readJson(
repositoryRoot,
"config/security/dependency-policy.json",
),
});
failures.push(...compareStoredLicenseEvidence(recomputed, license));
} catch {
failures.push("license policy evidence is missing or invalid");
}
}
const vulnerability = await parseArtifact(
repositoryRoot,
@@ -485,20 +601,13 @@ async function verifySecretScan(
failures: string[],
): Promise<void> {
try {
const sarif = asRecord(
await readJson(repositoryRoot, "artifacts/security/scan.sarif"),
"secret scan SARIF",
const evaluation = await evaluateRepositorySecretScan({ repositoryRoot });
failures.push(
...verifyStoredSecretScan(
evaluation,
await readJson(repositoryRoot, "artifacts/security/scan.sarif"),
),
);
const runs = Array.isArray(sarif.runs) ? sarif.runs : [];
if (
sarif.version !== "2.1.0" ||
runs.length !== 1 ||
!isRecord(runs[0]) ||
!Array.isArray(runs[0].results) ||
runs[0].results.length !== 0
) {
failures.push("secret scan SARIF is not an empty PASS");
}
} catch {
failures.push("secret scan SARIF is missing or invalid");
}
@@ -523,6 +632,17 @@ async function readJson(repositoryRoot: string, file: string): Promise<unknown>
return JSON.parse(await readFile(path.join(repositoryRoot, file), "utf8"));
}
async function optionalReadJson(
repositoryRoot: string,
file: string,
): Promise<unknown | null> {
try {
return await readJson(repositoryRoot, file);
} catch {
return null;
}
}
function asRecord(value: unknown, label: string): Record<string, unknown> {
if (!isRecord(value)) throw new TypeError(`${label} must be a JSON object`);
return value;
+272
View File
@@ -0,0 +1,272 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import {
buildRepositoryFileInventory,
parseRepositoryFileInventoryPolicy,
} from "./repository-file-inventory.ts";
import {
findSecretMatches,
secretScanRules,
type SecretAllowlistEntry,
type SecretFinding,
} from "./secret-scan.ts";
import {
parseSecretScanIncludedPaths,
selectIncludedInventoryFiles,
} from "./secret-scan-policy.ts";
import { supplyChainDigest } from "./supply-chain.ts";
const nonEmptyString = z.string().min(1);
const sarifRuleSchema = z
.object({
id: nonEmptyString,
shortDescription: z.object({ text: nonEmptyString }).strict(),
})
.strict();
const sarifResultSchema = z
.object({
ruleId: nonEmptyString,
message: z.object({ text: nonEmptyString }).strict(),
partialFingerprints: z
.object({ primaryLocationLineHash: nonEmptyString })
.strict()
.optional(),
locations: z
.array(
z
.object({
physicalLocation: z
.object({
artifactLocation: z.object({ uri: nonEmptyString }).strict(),
region: z.object({ startLine: z.int().positive() }).strict(),
})
.strict(),
})
.strict(),
)
.optional(),
})
.strict();
export const secretScanSarifSchema = z
.object({
version: z.literal("2.1.0"),
$schema: z.literal("https://json.schemastore.org/sarif-2.1.0.json"),
runs: z
.array(
z
.object({
tool: z
.object({
driver: z
.object({
name: z.literal("ca-frontend-secret-scan"),
rules: z.array(sarifRuleSchema),
})
.strict(),
})
.strict(),
results: z.array(sarifResultSchema),
})
.strict(),
)
.length(1),
})
.strict();
type AllowlistEntry = SecretAllowlistEntry &
Readonly<{ owner: string; reason: string }>;
export type SecretScanPolicy = Readonly<{
excludedPaths: readonly string[];
trackedRoots: readonly string[];
generatedRoots: readonly string[];
optionalRoots: readonly string[];
includedPaths: readonly string[] | null;
allowlist: readonly AllowlistEntry[];
}>;
export function parseSecretScanPolicy(value: unknown): SecretScanPolicy {
const document = isRecord(value) ? value : {};
const inventoryPolicy = parseRepositoryFileInventoryPolicy(value);
const allowlist = Array.isArray(document.allowlist)
? document.allowlist.map((rawEntry) => {
const entry = isRecord(rawEntry) ? rawEntry : {};
return Object.freeze({
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: inventoryPolicy.trackedRoots,
generatedRoots: inventoryPolicy.generatedRoots,
optionalRoots: inventoryPolicy.optionalRoots,
includedPaths: parseSecretScanIncludedPaths(document.includedPaths),
allowlist: Object.freeze(allowlist),
});
}
export async function evaluateRepositorySecretScan(input: Readonly<{
repositoryRoot?: string;
policyPath?: string;
now?: number;
}>) {
const repositoryRoot = path.resolve(input.repositoryRoot ?? process.cwd());
const policy = parseSecretScanPolicy(
JSON.parse(
await readFile(
path.resolve(
repositoryRoot,
input.policyPath ?? "config/security/secret-scan-policy.json",
),
"utf8",
),
) as unknown,
);
const inventory = await buildRepositoryFileInventory({
repositoryRoot,
trackedRoots: policy.trackedRoots,
generatedRoots: policy.generatedRoots,
optionalRoots: policy.optionalRoots,
});
return evaluateSecretScan({
policy,
inventoryFiles: inventory.files,
readText: (file) => readFile(path.join(repositoryRoot, file), "utf8"),
now: input.now,
});
}
export async function evaluateSecretScan(input: Readonly<{
policy: SecretScanPolicy;
inventoryFiles: readonly string[];
readText: (file: string) => Promise<string>;
now?: number;
}>) {
const now = input.now ?? Date.now();
const findings: SecretFinding[] = [];
const policyFailures: string[] = [];
for (const entry of input.policy.allowlist) {
const expiry = Date.parse(entry.expiresAt);
if (
!entry.path.startsWith("tests/") ||
!entry.owner.trim() ||
!entry.reason.trim() ||
!Number.isFinite(expiry) ||
expiry <= now
) {
policyFailures.push(
`invalid or expired secret allowlist entry: ${entry.path}:${entry.ruleId}`,
);
}
}
const scanFiles = selectIncludedInventoryFiles(
input.inventoryFiles,
input.policy.includedPaths,
);
const excluded = new Set(
input.policy.excludedPaths.map((entry) => entry.replaceAll("\\", "/")),
);
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)$/iu.test(normalized)
) {
continue;
}
findings.push(
...findSecretMatches(normalized, await input.readText(scanFile), {
allowlist: input.policy.allowlist,
now,
}),
);
}
const sarif = secretScanSarifSchema.parse({
version: "2.1.0",
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
runs: [
{
tool: {
driver: {
name: "ca-frontend-secret-scan",
rules: secretScanRules().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 },
})),
],
},
],
});
return Object.freeze({
findings: Object.freeze(findings),
policyFailures: Object.freeze(policyFailures),
scanFiles: Object.freeze([...scanFiles]),
sarif,
});
}
export function verifyStoredSecretScan(
evaluation: Awaited<ReturnType<typeof evaluateSecretScan>>,
stored: unknown,
): string[] {
const failures: string[] = [];
const blockingCount =
evaluation.findings.length + evaluation.policyFailures.length;
if (blockingCount > 0) {
failures.push(
`recomputed secret scan contains ${blockingCount} blocking result(s)`,
);
}
const parsed = secretScanSarifSchema.safeParse(stored);
if (
!parsed.success ||
supplyChainDigest(parsed.data) !== supplyChainDigest(evaluation.sarif)
) {
failures.push("stored secret scan SARIF does not match recomputed results");
}
return failures;
}
function strings(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
: [];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
+12 -158
View File
@@ -1,35 +1,7 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import {
buildRepositoryFileInventory,
parseRepositoryFileInventoryPolicy,
} from "./lib/repository-file-inventory.ts";
import {
findSecretMatches,
secretScanRules,
type SecretFinding,
} from "./lib/secret-scan.ts";
import {
parseSecretScanIncludedPaths,
selectIncludedInventoryFiles,
} from "./lib/secret-scan-policy.ts";
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[];
optionalRoots: readonly string[];
includedPaths: readonly string[] | null;
allowlist: readonly AllowlistEntry[];
}>;
import { evaluateRepositorySecretScan } from "./lib/secret-scan-evaluator.ts";
function argumentValue(name: string, fallback: string): string {
const index = process.argv.indexOf(name);
@@ -38,42 +10,6 @@ function argumentValue(name: string, fallback: string): string {
: 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 inventoryPolicy = parseRepositoryFileInventoryPolicy(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: inventoryPolicy.trackedRoots,
generatedRoots: inventoryPolicy.generatedRoots,
optionalRoots: inventoryPolicy.optionalRoots,
includedPaths: parseSecretScanIncludedPaths(document.includedPaths),
allowlist: Object.freeze(allowlist),
});
}
const policyPath = argumentValue(
"--policy",
"config/security/secret-scan-policy.json",
@@ -82,103 +18,21 @@ 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 = secretScanRules();
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 inventory = await buildRepositoryFileInventory({
trackedRoots: policy.trackedRoots,
generatedRoots: policy.generatedRoots,
optionalRoots: policy.optionalRoots,
});
const scanFiles = selectIncludedInventoryFiles(
inventory.files,
policy.includedPaths,
);
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;
}
const content = await readFile(scanFile, "utf8");
findings.push(
...findSecretMatches(normalized, content, { allowlist }),
);
}
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 },
})),
],
},
],
};
const evaluation = await evaluateRepositorySecretScan({ policyPath });
await mkdir(path.dirname(artifactPath), { recursive: true });
await writeFile(artifactPath, `${JSON.stringify(sarif, null, 2)}\n`);
if (findings.length > 0 || policyFailures.length > 0) {
await writeFile(
artifactPath,
`${JSON.stringify(evaluation.sarif, null, 2)}\n`,
);
const blockingCount =
evaluation.findings.length + evaluation.policyFailures.length;
if (blockingCount > 0) {
process.stderr.write(
`Security scan found ${findings.length + policyFailures.length} blocking result(s).\n`,
`Security scan found ${blockingCount} blocking result(s).\n`,
);
process.exit(1);
}
process.stdout.write(
`Tracked source, config, built asset and artifact secret scan: PASS (${scanFiles.length} files)\n`,
`Tracked source, config, built asset and artifact secret scan: PASS (${evaluation.scanFiles.length} files)\n`,
);
+176
View File
@@ -0,0 +1,176 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import {
compareStoredDependencyEvidence,
compareStoredLicenseEvidence,
distChecksumsText,
recomputeDependencyEvidence,
recomputeLicenseEvidence,
verifyStoredDistChecksums,
} from "../../scripts/lib/local-policy-evidence.ts";
import {
evaluateSecretScan,
parseSecretScanPolicy,
secretScanSarifSchema,
verifyStoredSecretScan,
} from "../../scripts/lib/secret-scan-evaluator.ts";
import { supplyChainDigest } from "../../scripts/lib/supply-chain.ts";
const sha512Integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`;
const dependency = {
name: "fixture",
version: "1.0.0",
direct: true,
scope: "production" as const,
optional: false,
license: "MIT",
integrity: sha512Integrity,
dependencies: [],
};
const inventory = {
schemaVersion: 2 as const,
packageManager: "pnpm@11.17.0",
lockfileSha256: "1".repeat(64),
dependencyCount: 1,
directDependencyCount: 1,
dependencies: [dependency],
};
describe("recomputed local promotion evidence", () => {
it("rejects a schema-valid dependency report with a forged semantic digest", () => {
const baseline = { ...inventory, dependencies: [] };
const recomputed = recomputeDependencyEvidence({
inventory,
baseline,
baselineApproval: {
schemaVersion: 1,
snapshotDigest: supplyChainDigest(baseline),
owner: "platform-security",
},
dependencyChangeEvidence: {
changes: [
{
changeId: "add:fixture@1.0.0",
owner: "dependency-owner",
reviewer: "security-reviewer",
reason: "fixture",
rollback: "remove fixture",
},
],
},
});
const tampered = {
...recomputed.report,
currentDigest: "f".repeat(64),
};
expect(compareStoredDependencyEvidence(recomputed, tampered)).toContain(
"stored dependency diff does not match recomputed policy evidence",
);
});
it("rejects a schema-valid PASS license report when policy recomputes FAIL", () => {
const recomputed = recomputeLicenseEvidence({
inventory,
policy: {
allowedLicenses: ["Apache-2.0"],
deniedLicensePatterns: ["MIT"],
},
});
const tampered = {
schemaVersion: 1 as const,
status: "PASS" as const,
dependencyCount: 1,
results: [
{
package: "fixture@1.0.0",
license: "MIT",
passed: true,
reason: null,
},
],
failures: [],
};
expect(compareStoredLicenseEvidence(recomputed, tampered)).toContain(
"stored license report does not match recomputed policy evidence",
);
});
it("rejects a checksum document that does not exactly describe sorted dist outputs", () => {
const outputs = [
{
path: "dist/z.js",
bytes: 1,
gzipBytes: 21,
sha256: "a".repeat(64),
},
{
path: "dist/a.js",
bytes: 1,
gzipBytes: 21,
sha256: "b".repeat(64),
},
];
expect(distChecksumsText(outputs)).toBe(
`${"b".repeat(64)} dist/a.js\n${"a".repeat(64)} dist/z.js\n`,
);
expect(
verifyStoredDistChecksums(outputs, `${"c".repeat(64)} dist/a.js\n`),
).toEqual(["stored dist checksums do not match current outputs"]);
});
it("rejects an empty schema-valid SARIF when real fixture source contains secrets", async () => {
const rawPolicy: unknown = JSON.parse(
await readFile(
"tests/fixtures/security/secret-detection/forbidden-policy.json",
"utf8",
),
);
const policy = parseSecretScanPolicy(rawPolicy);
const inventoryFiles = [
"tests/fixtures/security/secret-detection/forbidden/config.json",
"tests/fixtures/security/secret-detection/forbidden/dist.ts",
"tests/fixtures/security/secret-detection/forbidden/source.ts",
];
const evaluation = await evaluateSecretScan({
policy,
inventoryFiles,
readText: (file) => readFile(file, "utf8"),
now: Date.parse("2026-08-02T00:00:00.000Z"),
});
const fakeEmptySarif = secretScanSarifSchema.parse({
version: "2.1.0",
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
runs: [
{
tool: {
driver: {
name: "ca-frontend-secret-scan",
rules: [
"private-key",
"aws-access-key",
"github-token",
"assigned-secret",
].map((id) => ({
id,
shortDescription: { text: "Potential credential material" },
})),
},
},
results: [],
},
],
});
expect(evaluation.findings).toHaveLength(3);
expect(verifyStoredSecretScan(evaluation, fakeEmptySarif)).toEqual(
expect.arrayContaining([
"recomputed secret scan contains 3 blocking result(s)",
"stored secret scan SARIF does not match recomputed results",
]),
);
});
});
+8 -5
View File
@@ -675,11 +675,14 @@ describe("supply-chain policy", () => {
});
it("uses one fail-closed repository inventory for provenance and secret scanning", async () => {
const [provenanceSource, securitySource] = await Promise.all([
readFile("scripts/generate-supply-chain.ts", "utf8"),
readFile("scripts/security-scan.ts", "utf8"),
]);
for (const source of [provenanceSource, securitySource]) {
const [provenanceSource, securityCliSource, securityEvaluatorSource] =
await Promise.all([
readFile("scripts/generate-supply-chain.ts", "utf8"),
readFile("scripts/security-scan.ts", "utf8"),
readFile("scripts/lib/secret-scan-evaluator.ts", "utf8"),
]);
expect(securityCliSource).toContain("evaluateRepositorySecretScan");
for (const source of [provenanceSource, securityEvaluatorSource]) {
expect(source).toContain("buildRepositoryFileInventory");
expect(source).not.toContain("async function filesWithin");
}