From 1b4b0c28214aa587b4ced1188e9bc2af4003c695 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sun, 2 Aug 2026 07:02:39 +0900 Subject: [PATCH] fix: recompute local promotion evidence --- .../check-supply-chain-provider-fixtures.ts | 49 +++- scripts/generate-supply-chain.ts | 78 ++--- scripts/lib/local-policy-evidence.ts | 150 ++++++++++ scripts/lib/local-release-evidence.ts | 182 ++++++++++-- scripts/lib/secret-scan-evaluator.ts | 272 ++++++++++++++++++ scripts/security-scan.ts | 170 +---------- tests/unit/local-promotion-evidence.test.ts | 176 ++++++++++++ tests/unit/supply-chain.test.ts | 13 +- 8 files changed, 837 insertions(+), 253 deletions(-) create mode 100644 scripts/lib/local-policy-evidence.ts create mode 100644 scripts/lib/secret-scan-evaluator.ts create mode 100644 tests/unit/local-promotion-evidence.test.ts diff --git a/scripts/check-supply-chain-provider-fixtures.ts b/scripts/check-supply-chain-provider-fixtures.ts index 9a6fa0e..8775b5d 100644 --- a/scripts/check-supply-chain-provider-fixtures.ts +++ b/scripts/check-supply-chain-provider-fixtures.ts @@ -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, diff --git a/scripts/generate-supply-chain.ts b/scripts/generate-supply-chain.ts index c82adba..ce1eea4 100644 --- a/scripts/generate-supply-chain.ts +++ b/scripts/generate-supply-chain.ts @@ -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 = 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", diff --git a/scripts/lib/local-policy-evidence.ts b/scripts/lib/local-policy-evidence.ts new file mode 100644 index 0000000..68dc957 --- /dev/null +++ b/scripts/lib/local-policy-evidence.ts @@ -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; + +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 = Object.freeze({ + added: Object.freeze([]), + removed: Object.freeze([]), + changed: Object.freeze([]), + upgrades: Object.freeze([]), + }); + let review: ReturnType = 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, + 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, + 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; +} diff --git a/scripts/lib/local-release-evidence.ts b/scripts/lib/local-release-evidence.ts index 0f70dfc..d5dc04a 100644 --- a/scripts/lib/local-release-evidence.ts +++ b/scripts/lib/local-release-evidence.ts @@ -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 { + let actualOutputs: Awaited> | 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 { 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 return JSON.parse(await readFile(path.join(repositoryRoot, file), "utf8")); } +async function optionalReadJson( + repositoryRoot: string, + file: string, +): Promise { + try { + return await readJson(repositoryRoot, file); + } catch { + return null; + } +} + function asRecord(value: unknown, label: string): Record { if (!isRecord(value)) throw new TypeError(`${label} must be a JSON object`); return value; diff --git a/scripts/lib/secret-scan-evaluator.ts b/scripts/lib/secret-scan-evaluator.ts new file mode 100644 index 0000000..3f89988 --- /dev/null +++ b/scripts/lib/secret-scan-evaluator.ts @@ -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; + 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>, + 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 { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/scripts/security-scan.ts b/scripts/security-scan.ts index 91beeb8..9e8cebe 100644 --- a/scripts/security-scan.ts +++ b/scripts/security-scan.ts @@ -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 { - 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`, ); diff --git a/tests/unit/local-promotion-evidence.test.ts b/tests/unit/local-promotion-evidence.test.ts new file mode 100644 index 0000000..702975c --- /dev/null +++ b/tests/unit/local-promotion-evidence.test.ts @@ -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", + ]), + ); + }); +}); diff --git a/tests/unit/supply-chain.test.ts b/tests/unit/supply-chain.test.ts index d08f597..2c07065 100644 --- a/tests/unit/supply-chain.test.ts +++ b/tests/unit/supply-chain.test.ts @@ -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"); }