fix: recompute local promotion evidence
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { createHash, generateKeyPairSync, sign } from "node:crypto";
|
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 { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
@@ -9,12 +9,35 @@ import {
|
|||||||
createReleaseCandidateManifest,
|
createReleaseCandidateManifest,
|
||||||
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
||||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||||
|
releaseCandidateManifestSchema,
|
||||||
} from "./lib/release-candidate.ts";
|
} from "./lib/release-candidate.ts";
|
||||||
|
|
||||||
const fixtureRoot = await mkdtemp(
|
const fixtureRoot = await mkdtemp(
|
||||||
path.join(tmpdir(), "supply-chain-provider-fixture-"),
|
path.join(tmpdir(), "supply-chain-provider-fixture-"),
|
||||||
);
|
);
|
||||||
try {
|
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 rawLockfile = "lockfileVersion: '9.0'\n";
|
||||||
const lockfileSha256 = createHash("sha256")
|
const lockfileSha256 = createHash("sha256")
|
||||||
.update(rawLockfile)
|
.update(rawLockfile)
|
||||||
@@ -86,6 +109,7 @@ try {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const passed =
|
const passed =
|
||||||
|
actualDefaultVerifier.status === "PASS" &&
|
||||||
fixtures.validImmutable.status === "PASS" &&
|
fixtures.validImmutable.status === "PASS" &&
|
||||||
fixtures.absent.status === "FAIL_UNVERIFIED" &&
|
fixtures.absent.status === "FAIL_UNVERIFIED" &&
|
||||||
fixtures.wrongDigest.status === "FAIL_UNVERIFIED" &&
|
fixtures.wrongDigest.status === "FAIL_UNVERIFIED" &&
|
||||||
@@ -96,6 +120,10 @@ try {
|
|||||||
`${JSON.stringify(
|
`${JSON.stringify(
|
||||||
{
|
{
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
|
actualDefaultVerifier: {
|
||||||
|
status: actualDefaultVerifier.status,
|
||||||
|
failures: actualDefaultVerifier.failures,
|
||||||
|
},
|
||||||
fixtures: Object.fromEntries(
|
fixtures: Object.fromEntries(
|
||||||
Object.entries(fixtures).map(([name, result]) => [
|
Object.entries(fixtures).map(([name, result]) => [
|
||||||
name,
|
name,
|
||||||
@@ -118,13 +146,30 @@ try {
|
|||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
} else {
|
} else {
|
||||||
process.stdout.write(
|
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 {
|
} finally {
|
||||||
await rm(fixtureRoot, { recursive: true, force: true });
|
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(
|
async function writeProviderEnvironment(
|
||||||
repositoryRoot: string,
|
repositoryRoot: string,
|
||||||
name: string,
|
name: string,
|
||||||
|
|||||||
@@ -18,16 +18,17 @@ import {
|
|||||||
vulnerabilityReportArtifactSchema,
|
vulnerabilityReportArtifactSchema,
|
||||||
} from "./contracts/release-artifacts.ts";
|
} from "./contracts/release-artifacts.ts";
|
||||||
import {
|
import {
|
||||||
diffDependencyInventories,
|
|
||||||
flattenPnpmDependencyTree,
|
flattenPnpmDependencyTree,
|
||||||
isValidSha512Integrity,
|
isValidSha512Integrity,
|
||||||
parsePnpmLockfilePackages,
|
parsePnpmLockfilePackages,
|
||||||
supplyChainDigest,
|
supplyChainDigest,
|
||||||
validateDependencyReview,
|
|
||||||
validateLicensePolicy,
|
|
||||||
verifySupplyChainCoherence,
|
verifySupplyChainCoherence,
|
||||||
type DependencyInventoryDiff,
|
|
||||||
} from "./lib/supply-chain.ts";
|
} from "./lib/supply-chain.ts";
|
||||||
|
import {
|
||||||
|
distChecksumsText,
|
||||||
|
recomputeDependencyEvidence,
|
||||||
|
recomputeLicenseEvidence,
|
||||||
|
} from "./lib/local-policy-evidence.ts";
|
||||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||||
import { digestReleaseInputFiles } from "./lib/release-input-evidence.ts";
|
import { digestReleaseInputFiles } from "./lib/release-input-evidence.ts";
|
||||||
import {
|
import {
|
||||||
@@ -159,7 +160,6 @@ const inventory = await buildDependencyInventory();
|
|||||||
const licensePolicy = JSON.parse(
|
const licensePolicy = JSON.parse(
|
||||||
await readFile("config/security/dependency-policy.json", "utf8"),
|
await readFile("config/security/dependency-policy.json", "utf8"),
|
||||||
);
|
);
|
||||||
const licenseResult = validateLicensePolicy(inventory, licensePolicy);
|
|
||||||
|
|
||||||
const baseline = await optionalJson(
|
const baseline = await optionalJson(
|
||||||
"config/security/dependency-baseline.json",
|
"config/security/dependency-baseline.json",
|
||||||
@@ -174,39 +174,17 @@ const dependencyEvidence = JSON.parse(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const skipsBaseline = process.argv.includes("--no-baseline");
|
const skipsBaseline = process.argv.includes("--no-baseline");
|
||||||
const baselineFailures: string[] = [];
|
const dependencyPolicy = recomputeDependencyEvidence({
|
||||||
let dependencyDiff: DependencyInventoryDiff = Object.freeze({
|
inventory,
|
||||||
added: Object.freeze([]),
|
baseline,
|
||||||
removed: Object.freeze([]),
|
baselineApproval,
|
||||||
changed: Object.freeze([]),
|
dependencyChangeEvidence: dependencyEvidence,
|
||||||
upgrades: Object.freeze([]),
|
skipBaseline: skipsBaseline,
|
||||||
});
|
});
|
||||||
let reviewResult: ReturnType<typeof validateDependencyReview> = Object.freeze({
|
const licenseEvidence = recomputeLicenseEvidence({
|
||||||
passed: skipsBaseline,
|
inventory,
|
||||||
highRisk: Object.freeze([]),
|
policy: licensePolicy,
|
||||||
failures: Object.freeze(
|
|
||||||
skipsBaseline ? [] : ["dependency baseline unavailable"],
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
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 = {
|
const vulnerabilityReport = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -315,9 +293,8 @@ const coherence = verifySupplyChainCoherence(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const localFailures = [
|
const localFailures = [
|
||||||
...licenseResult.failures,
|
...licenseEvidence.failures,
|
||||||
...baselineFailures,
|
...dependencyPolicy.failures,
|
||||||
...reviewResult.failures,
|
|
||||||
...coherence.failures,
|
...coherence.failures,
|
||||||
];
|
];
|
||||||
const localPassed = localFailures.length === 0;
|
const localPassed = localFailures.length === 0;
|
||||||
@@ -329,8 +306,8 @@ const verification = {
|
|||||||
sourceSetSha256,
|
sourceSetSha256,
|
||||||
distSha256: distDigest,
|
distSha256: distDigest,
|
||||||
sbomSha256: supplyChainDigest(sbom),
|
sbomSha256: supplyChainDigest(sbom),
|
||||||
dependencyDiff,
|
dependencyDiff: dependencyPolicy.dependencyDiff,
|
||||||
highRiskReview: reviewResult.highRisk,
|
highRiskReview: dependencyPolicy.highRisk,
|
||||||
vulnerabilityStatus: vulnerabilityReport.status,
|
vulnerabilityStatus: vulnerabilityReport.status,
|
||||||
provenanceAttestationStatus: "FAIL_UNVERIFIED",
|
provenanceAttestationStatus: "FAIL_UNVERIFIED",
|
||||||
failures: localFailures,
|
failures: localFailures,
|
||||||
@@ -349,21 +326,8 @@ const bundleReport = {
|
|||||||
},
|
},
|
||||||
outputs,
|
outputs,
|
||||||
};
|
};
|
||||||
const dependencyDiffReport = {
|
const dependencyDiffReport = dependencyPolicy.report;
|
||||||
schemaVersion: 2,
|
const licenseReport = licenseEvidence.report;
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
await mkdir("artifacts/performance", { recursive: true });
|
await mkdir("artifacts/performance", { recursive: true });
|
||||||
await mkdir("artifacts/release", { recursive: true });
|
await mkdir("artifacts/release", { recursive: true });
|
||||||
@@ -390,7 +354,7 @@ await writeValidatedJsonArtifact({
|
|||||||
});
|
});
|
||||||
await writeFile(
|
await writeFile(
|
||||||
"artifacts/release/checksums.txt",
|
"artifacts/release/checksums.txt",
|
||||||
`${outputs.map((output) => `${output.sha256} ${output.path}`).join("\n")}\n`,
|
distChecksumsText(outputs),
|
||||||
);
|
);
|
||||||
await writeValidatedJsonArtifact({
|
await writeValidatedJsonArtifact({
|
||||||
path: "artifacts/security/dependency-diff.json",
|
path: "artifacts/security/dependency-diff.json",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -32,10 +32,21 @@ import type { ReleaseCandidateManifest } from "./release-candidate.ts";
|
|||||||
import { collectDistOutputs, distSha256 } from "./release-candidate.ts";
|
import { collectDistOutputs, distSha256 } from "./release-candidate.ts";
|
||||||
import { verifyReleaseRuntimeCoherence } from "./release-runtime-coherence.ts";
|
import { verifyReleaseRuntimeCoherence } from "./release-runtime-coherence.ts";
|
||||||
import { digestReleaseInputFiles } from "./release-input-evidence.ts";
|
import { digestReleaseInputFiles } from "./release-input-evidence.ts";
|
||||||
|
import {
|
||||||
|
compareStoredDependencyEvidence,
|
||||||
|
compareStoredLicenseEvidence,
|
||||||
|
recomputeDependencyEvidence,
|
||||||
|
recomputeLicenseEvidence,
|
||||||
|
verifyStoredDistChecksums,
|
||||||
|
} from "./local-policy-evidence.ts";
|
||||||
import {
|
import {
|
||||||
buildRepositoryFileInventory,
|
buildRepositoryFileInventory,
|
||||||
parseRepositoryFileInventoryPolicy,
|
parseRepositoryFileInventoryPolicy,
|
||||||
} from "./repository-file-inventory.ts";
|
} from "./repository-file-inventory.ts";
|
||||||
|
import {
|
||||||
|
evaluateRepositorySecretScan,
|
||||||
|
verifyStoredSecretScan,
|
||||||
|
} from "./secret-scan-evaluator.ts";
|
||||||
import {
|
import {
|
||||||
isValidSha512Integrity,
|
isValidSha512Integrity,
|
||||||
parsePnpmLockfilePackages,
|
parsePnpmLockfilePackages,
|
||||||
@@ -137,15 +148,15 @@ export async function verifyLocalSupplyChainEvidence(
|
|||||||
}
|
}
|
||||||
const sbomSha256 = sbom ? supplyChainDigest(sbom) : "0".repeat(64);
|
const sbomSha256 = sbom ? supplyChainDigest(sbom) : "0".repeat(64);
|
||||||
|
|
||||||
|
let coherenceFailures: readonly string[] = Object.freeze([]);
|
||||||
if (inventory && sbom && provenance) {
|
if (inventory && sbom && provenance) {
|
||||||
failures.push(
|
coherenceFailures = verifySupplyChainCoherence(
|
||||||
...verifySupplyChainCoherence(
|
sbom,
|
||||||
sbom,
|
inventory,
|
||||||
inventory,
|
provenance,
|
||||||
provenance,
|
distDigest,
|
||||||
distDigest,
|
).failures;
|
||||||
).failures,
|
failures.push(...coherenceFailures);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!inventory ||
|
!inventory ||
|
||||||
@@ -193,6 +204,53 @@ export async function verifyLocalSupplyChainEvidence(
|
|||||||
failures.push("release source inventory is unavailable or unreadable");
|
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 lockRows = parsePnpmLockfilePackages(lockfileText);
|
||||||
const inventoryRows = inventory?.dependencies ?? [];
|
const inventoryRows = inventory?.dependencies ?? [];
|
||||||
@@ -366,6 +424,13 @@ async function validateSupportingArtifacts(
|
|||||||
repositoryRoot: string,
|
repositoryRoot: string,
|
||||||
failures: string[],
|
failures: string[],
|
||||||
): Promise<void> {
|
): 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(
|
const bundle = await parseArtifact(
|
||||||
repositoryRoot,
|
repositoryRoot,
|
||||||
"artifacts/performance/bundle.json",
|
"artifacts/performance/bundle.json",
|
||||||
@@ -373,16 +438,35 @@ async function validateSupportingArtifacts(
|
|||||||
"bundle report",
|
"bundle report",
|
||||||
failures,
|
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 {
|
try {
|
||||||
const actual = await collectDistOutputs(repositoryRoot);
|
failures.push(
|
||||||
if (JSON.stringify(bundle.outputs) !== JSON.stringify(actual)) {
|
...verifyStoredDistChecksums(
|
||||||
failures.push("bundle report does not describe current dist bytes");
|
actualOutputs,
|
||||||
}
|
await readFile(
|
||||||
|
path.join(repositoryRoot, "artifacts/release/checksums.txt"),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch {
|
} 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(
|
const dependencyDiff = await parseArtifact(
|
||||||
repositoryRoot,
|
repositoryRoot,
|
||||||
"artifacts/security/dependency-diff.json",
|
"artifacts/security/dependency-diff.json",
|
||||||
@@ -390,8 +474,29 @@ async function validateSupportingArtifacts(
|
|||||||
"dependency diff",
|
"dependency diff",
|
||||||
failures,
|
failures,
|
||||||
);
|
);
|
||||||
if (dependencyDiff && dependencyDiff.reviewFailures.length > 0) {
|
if (inventory && dependencyDiff) {
|
||||||
failures.push("dependency review evidence is not PASS");
|
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(
|
const license = await parseArtifact(
|
||||||
repositoryRoot,
|
repositoryRoot,
|
||||||
@@ -400,8 +505,19 @@ async function validateSupportingArtifacts(
|
|||||||
"license report",
|
"license report",
|
||||||
failures,
|
failures,
|
||||||
);
|
);
|
||||||
if (license && (license.status !== "PASS" || license.failures.length > 0)) {
|
if (inventory && license) {
|
||||||
failures.push("license report is not PASS");
|
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(
|
const vulnerability = await parseArtifact(
|
||||||
repositoryRoot,
|
repositoryRoot,
|
||||||
@@ -485,20 +601,13 @@ async function verifySecretScan(
|
|||||||
failures: string[],
|
failures: string[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const sarif = asRecord(
|
const evaluation = await evaluateRepositorySecretScan({ repositoryRoot });
|
||||||
await readJson(repositoryRoot, "artifacts/security/scan.sarif"),
|
failures.push(
|
||||||
"secret scan SARIF",
|
...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 {
|
} catch {
|
||||||
failures.push("secret scan SARIF is missing or invalid");
|
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"));
|
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> {
|
function asRecord(value: unknown, label: string): Record<string, unknown> {
|
||||||
if (!isRecord(value)) throw new TypeError(`${label} must be a JSON object`);
|
if (!isRecord(value)) throw new TypeError(`${label} must be a JSON object`);
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@@ -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
@@ -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 path from "node:path";
|
||||||
|
|
||||||
import {
|
import { evaluateRepositorySecretScan } from "./lib/secret-scan-evaluator.ts";
|
||||||
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[];
|
|
||||||
}>;
|
|
||||||
|
|
||||||
function argumentValue(name: string, fallback: string): string {
|
function argumentValue(name: string, fallback: string): string {
|
||||||
const index = process.argv.indexOf(name);
|
const index = process.argv.indexOf(name);
|
||||||
@@ -38,42 +10,6 @@ function argumentValue(name: string, fallback: string): string {
|
|||||||
: fallback;
|
: 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(
|
const policyPath = argumentValue(
|
||||||
"--policy",
|
"--policy",
|
||||||
"config/security/secret-scan-policy.json",
|
"config/security/secret-scan-policy.json",
|
||||||
@@ -82,103 +18,21 @@ const artifactPath = argumentValue(
|
|||||||
"--artifact",
|
"--artifact",
|
||||||
"artifacts/security/scan.sarif",
|
"artifacts/security/scan.sarif",
|
||||||
);
|
);
|
||||||
const rawPolicy: unknown = JSON.parse(await readFile(policyPath, "utf8"));
|
const evaluation = await evaluateRepositorySecretScan({ policyPath });
|
||||||
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 },
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
await mkdir(path.dirname(artifactPath), { recursive: true });
|
await mkdir(path.dirname(artifactPath), { recursive: true });
|
||||||
await writeFile(artifactPath, `${JSON.stringify(sarif, null, 2)}\n`);
|
await writeFile(
|
||||||
if (findings.length > 0 || policyFailures.length > 0) {
|
artifactPath,
|
||||||
|
`${JSON.stringify(evaluation.sarif, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
const blockingCount =
|
||||||
|
evaluation.findings.length + evaluation.policyFailures.length;
|
||||||
|
if (blockingCount > 0) {
|
||||||
process.stderr.write(
|
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.exit(1);
|
||||||
}
|
}
|
||||||
process.stdout.write(
|
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`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -675,11 +675,14 @@ describe("supply-chain policy", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses one fail-closed repository inventory for provenance and secret scanning", async () => {
|
it("uses one fail-closed repository inventory for provenance and secret scanning", async () => {
|
||||||
const [provenanceSource, securitySource] = await Promise.all([
|
const [provenanceSource, securityCliSource, securityEvaluatorSource] =
|
||||||
readFile("scripts/generate-supply-chain.ts", "utf8"),
|
await Promise.all([
|
||||||
readFile("scripts/security-scan.ts", "utf8"),
|
readFile("scripts/generate-supply-chain.ts", "utf8"),
|
||||||
]);
|
readFile("scripts/security-scan.ts", "utf8"),
|
||||||
for (const source of [provenanceSource, securitySource]) {
|
readFile("scripts/lib/secret-scan-evaluator.ts", "utf8"),
|
||||||
|
]);
|
||||||
|
expect(securityCliSource).toContain("evaluateRepositorySecretScan");
|
||||||
|
for (const source of [provenanceSource, securityEvaluatorSource]) {
|
||||||
expect(source).toContain("buildRepositoryFileInventory");
|
expect(source).toContain("buildRepositoryFileInventory");
|
||||||
expect(source).not.toContain("async function filesWithin");
|
expect(source).not.toContain("async function filesWithin");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user