refactor: adapter 구현중..
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
import { parseAsync } from "@babel/core";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, readdir } from "node:fs/promises";
|
||||
import { dirname, extname, isAbsolute, relative, resolve, sep } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import { architectureDependencyReportArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
type PathRule = Readonly<{ path?: string; pathNot?: string }>;
|
||||
type ArchitectureRule = Readonly<{
|
||||
name: string;
|
||||
@@ -156,10 +159,11 @@ dependencyReport.staticImportGraph = {
|
||||
typeScriptOnlySourcePolicy: typeScriptOnlyPolicy,
|
||||
};
|
||||
|
||||
await writeFile(
|
||||
qualityArtifact,
|
||||
`${JSON.stringify(dependencyReport, null, 2)}\n`,
|
||||
);
|
||||
await writeValidatedJsonArtifact({
|
||||
path: qualityArtifact,
|
||||
schema: architectureDependencyReportArtifactSchema,
|
||||
value: dependencyReport,
|
||||
});
|
||||
|
||||
let architectureFailed = false;
|
||||
|
||||
|
||||
+14
-10
@@ -1,10 +1,13 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { evaluateBundleBudget } from "../src/application/policies/performance-budgets.ts";
|
||||
import {
|
||||
bundleOutputInventoryArtifactSchema,
|
||||
bundlePerformanceArtifactSchema,
|
||||
} from "./contracts/release-artifacts.ts";
|
||||
import { classifyViteJavascript } from "./lib/classify-vite-bundle.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
type BundleOutput = { path: string; gzipBytes: number };
|
||||
type BundleReport = { outputs: BundleOutput[]; [key: string]: unknown };
|
||||
type ViteManifest = Record<
|
||||
string,
|
||||
{ file: string; isEntry?: boolean; imports?: string[] }
|
||||
@@ -14,9 +17,9 @@ type BundleBudgets = {
|
||||
lazyChunkGzipBytes: number;
|
||||
};
|
||||
|
||||
const report = JSON.parse(
|
||||
await readFile("artifacts/performance/bundle.json", "utf8"),
|
||||
) as BundleReport;
|
||||
const report = bundleOutputInventoryArtifactSchema.parse(
|
||||
JSON.parse(await readFile("artifacts/performance/bundle.json", "utf8")) as unknown,
|
||||
);
|
||||
const viteManifest = JSON.parse(
|
||||
await readFile("dist/.vite/manifest.json", "utf8"),
|
||||
) as ViteManifest;
|
||||
@@ -87,10 +90,11 @@ const completedReport = {
|
||||
passed,
|
||||
};
|
||||
|
||||
await writeFile(
|
||||
"artifacts/performance/bundle.json",
|
||||
`${JSON.stringify(completedReport, null, 2)}\n`,
|
||||
);
|
||||
await writeValidatedJsonArtifact({
|
||||
path: "artifacts/performance/bundle.json",
|
||||
schema: bundlePerformanceArtifactSchema,
|
||||
value: completedReport,
|
||||
});
|
||||
if (!passed) {
|
||||
process.stderr.write(
|
||||
`Bundle budget or manifest integrity failed: ${[
|
||||
|
||||
@@ -14,7 +14,10 @@ import { generateCiWorkflow, renderCiWorkflow } from "./generate-ci-workflow.ts"
|
||||
import {
|
||||
ciContractReportSchema,
|
||||
} from "./lib/ci-contract-report.ts";
|
||||
import { validatePackageScriptGraph } from "./lib/package-script-graph.ts";
|
||||
import {
|
||||
validateInstallScriptPolicy,
|
||||
validatePackageScriptGraph,
|
||||
} from "./lib/package-script-graph.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
const removalFixtureMode = process.argv.includes("--reduced-removal-fixture");
|
||||
@@ -35,7 +38,6 @@ if (!/^\d+\.\d+\.\d+$/u.test(nodeVersion)) {
|
||||
for (const script of [
|
||||
"build:release-candidate",
|
||||
"verify:local-evidence",
|
||||
"verify:provider-evidence",
|
||||
"verify:promotion",
|
||||
"generate:ci-workflow",
|
||||
"check:ci-workflow",
|
||||
@@ -54,6 +56,12 @@ if (/\b(?:build|rebuild)(?::[\w-]+)?\b/u.test(packageScripts["verify:promotion"]
|
||||
failures.push("verify:promotion must not build or rebuild candidate bytes");
|
||||
}
|
||||
failures.push(...validatePackageScriptGraph(packageScripts, "check:ci"));
|
||||
failures.push(
|
||||
...validateInstallScriptPolicy(
|
||||
packageScripts,
|
||||
[...new Set(contract.commands.map(({ script }) => script))],
|
||||
),
|
||||
);
|
||||
|
||||
const immutable = index.gates.get("FE-GATE-015");
|
||||
const immutableCommands = immutable?.commandIds.map((id) => index.commands.get(id)?.script);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
|
||||
import { classifyObjectSchemaChange } from "../src/application/policies/compatibility.ts";
|
||||
import { compatibilityFixturesArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
type CompatibilitySchema = Readonly<{
|
||||
required?: readonly string[];
|
||||
@@ -32,25 +34,22 @@ for (const [family, cases] of Object.entries(fixtures.families)) {
|
||||
}
|
||||
|
||||
await mkdir("artifacts/release", { recursive: true });
|
||||
await writeFile(
|
||||
"artifacts/release/compatibility.json",
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
rules: [
|
||||
"additive changes preserve required fields",
|
||||
"breaking changes require version bump and migration, discard, fallback, or rollback",
|
||||
"config and API major versions must match",
|
||||
"incompatible persisted cache is discarded by default",
|
||||
"rollback uses a coherent compatibility tuple",
|
||||
],
|
||||
results,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await writeValidatedJsonArtifact({
|
||||
path: "artifacts/release/compatibility.json",
|
||||
schema: compatibilityFixturesArtifactSchema,
|
||||
value: {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
rules: [
|
||||
"additive changes preserve required fields",
|
||||
"breaking changes require version bump and migration, discard, fallback, or rollback",
|
||||
"config and API major versions must match",
|
||||
"incompatible persisted cache is discarded by default",
|
||||
"rollback uses a coherent compatibility tuple",
|
||||
],
|
||||
results,
|
||||
},
|
||||
});
|
||||
|
||||
if (results.some((result) => !result.passed)) {
|
||||
process.stderr.write("Compatibility fixture classification failed.\n");
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, readdir } from "node:fs/promises";
|
||||
|
||||
import { designSystemReportArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
import path from "node:path";
|
||||
|
||||
import { REQUIRED_COMPONENT_TOKENS, REQUIRED_PRIMITIVE_TOKENS, REQUIRED_SEMANTIC_TOKENS } from "../src/presentation/design-system/tokens/token-contract.ts";
|
||||
@@ -118,7 +121,7 @@ for (const file of sources) {
|
||||
}
|
||||
}
|
||||
|
||||
const report = {
|
||||
const report = designSystemReportArtifactSchema.parse({
|
||||
schemaVersion: 1,
|
||||
mode: fixtureMode ? "negative-fixture" : "source",
|
||||
checkedTokenCount:
|
||||
@@ -127,14 +130,15 @@ const report = {
|
||||
REQUIRED_COMPONENT_TOKENS.length,
|
||||
failures,
|
||||
passed: failures.length === 0,
|
||||
};
|
||||
});
|
||||
await mkdir("artifacts/quality", { recursive: true });
|
||||
await writeFile(
|
||||
fixtureMode
|
||||
await writeValidatedJsonArtifact({
|
||||
path: fixtureMode
|
||||
? "artifacts/quality/design-system-fixture.json"
|
||||
: "artifacts/quality/design-system.json",
|
||||
`${JSON.stringify(report, null, 2)}\n`,
|
||||
);
|
||||
schema: designSystemReportArtifactSchema,
|
||||
value: report,
|
||||
});
|
||||
|
||||
if (failures.length > 0) {
|
||||
process.stderr.write(`Design system contract failed:\n${failures.join("\n")}\n`);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, readdir } from "node:fs/promises";
|
||||
|
||||
import { diagnosticsReportArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
import path from "node:path";
|
||||
|
||||
import { DIAGNOSTIC_EVENT_REGISTRY } from "../src/contracts/diagnostics.ts";
|
||||
@@ -89,7 +92,7 @@ for (const file of sources) {
|
||||
}
|
||||
}
|
||||
|
||||
const report = {
|
||||
const report = diagnosticsReportArtifactSchema.parse({
|
||||
schemaVersion: 1,
|
||||
mode: fixtureMode ? "negative-fixture" : "source",
|
||||
telemetryEventCount: Object.keys(TELEMETRY_REGISTRY).length,
|
||||
@@ -97,14 +100,15 @@ const report = {
|
||||
checkedFiles: sources.length,
|
||||
failures,
|
||||
passed: failures.length === 0,
|
||||
};
|
||||
});
|
||||
await mkdir("artifacts/quality", { recursive: true });
|
||||
await writeFile(
|
||||
fixtureMode
|
||||
await writeValidatedJsonArtifact({
|
||||
path: fixtureMode
|
||||
? "artifacts/quality/diagnostics-fixture.json"
|
||||
: "artifacts/quality/diagnostics.json",
|
||||
`${JSON.stringify(report, null, 2)}\n`,
|
||||
);
|
||||
schema: diagnosticsReportArtifactSchema,
|
||||
value: report,
|
||||
});
|
||||
if (failures.length > 0) {
|
||||
process.stderr.write(
|
||||
`Diagnostics contract failed:\n${failures.join("\n")}\n`,
|
||||
|
||||
+11
-7
@@ -1,4 +1,7 @@
|
||||
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, readdir } from "node:fs/promises";
|
||||
|
||||
import { i18nReportArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
import path from "node:path";
|
||||
|
||||
import { EN_MESSAGES, KO_MESSAGES, MESSAGE_CATALOGS } from "../src/presentation/i18n/catalog.ts";
|
||||
@@ -77,7 +80,7 @@ for (const file of sources) {
|
||||
}
|
||||
}
|
||||
|
||||
const report = {
|
||||
const report = i18nReportArtifactSchema.parse({
|
||||
schemaVersion: 1,
|
||||
mode: fixtureMode ? "negative-fixture" : "source",
|
||||
localeCount: Object.keys(MESSAGE_CATALOGS).length + 1,
|
||||
@@ -85,14 +88,15 @@ const report = {
|
||||
checkedFiles: sources.length,
|
||||
failures,
|
||||
passed: failures.length === 0,
|
||||
};
|
||||
});
|
||||
await mkdir("artifacts/quality", { recursive: true });
|
||||
await writeFile(
|
||||
fixtureMode
|
||||
await writeValidatedJsonArtifact({
|
||||
path: fixtureMode
|
||||
? "artifacts/quality/i18n-fixture.json"
|
||||
: "artifacts/quality/i18n.json",
|
||||
`${JSON.stringify(report, null, 2)}\n`,
|
||||
);
|
||||
schema: i18nReportArtifactSchema,
|
||||
value: report,
|
||||
});
|
||||
|
||||
if (failures.length > 0) {
|
||||
process.stderr.write(`I18n contract failed:\n${failures.join("\n")}\n`);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
|
||||
import { measureOptionalRecipeBundle } from "./lib/optional-recipe-bundle.ts";
|
||||
import { optionalRecipeFixturesArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||
import {
|
||||
scanOptionalRecipeSources,
|
||||
scanProductionBundle,
|
||||
validateRecipeCatalog,
|
||||
} from "./lib/optional-recipes.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
type ReferenceRuntimeRecipe = Readonly<{
|
||||
id: string;
|
||||
@@ -176,17 +178,18 @@ const results = [
|
||||
),
|
||||
},
|
||||
];
|
||||
const report = {
|
||||
const report = optionalRecipeFixturesArtifactSchema.parse({
|
||||
schemaVersion: 1,
|
||||
results,
|
||||
bundleBudgetFixtures,
|
||||
passed: results.every(({ passed }) => passed),
|
||||
};
|
||||
});
|
||||
await mkdir("artifacts/quality", { recursive: true });
|
||||
await writeFile(
|
||||
"artifacts/quality/optional-recipe-fixtures.json",
|
||||
`${JSON.stringify(report, null, 2)}\n`,
|
||||
);
|
||||
await writeValidatedJsonArtifact({
|
||||
path: "artifacts/quality/optional-recipe-fixtures.json",
|
||||
schema: optionalRecipeFixturesArtifactSchema,
|
||||
value: report,
|
||||
});
|
||||
if (!report.passed) {
|
||||
process.stderr.write(
|
||||
`Optional recipe negative fixtures failed: ${results
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, stat } from "node:fs/promises";
|
||||
|
||||
import { optionalRecipesArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||
import {
|
||||
measureOptionalRecipeBundle,
|
||||
type OptionalRecipeBundleMeasurement,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
validateRecipeCatalog,
|
||||
} from "./lib/optional-recipes.ts";
|
||||
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
type GateViolation = Readonly<{
|
||||
ruleId: string;
|
||||
@@ -141,7 +143,7 @@ const violations: GateViolation[] = [
|
||||
...referenceRuntimeBundleConfigurationViolations,
|
||||
...referenceRuntimeBundleMeasurementViolations,
|
||||
];
|
||||
const report = {
|
||||
const report = optionalRecipesArtifactSchema.parse({
|
||||
schemaVersion: 1,
|
||||
decisionId: "VD-10",
|
||||
selectedCapabilities: [],
|
||||
@@ -169,9 +171,13 @@ const report = {
|
||||
: "NOT_BUILT",
|
||||
violations,
|
||||
passed: violations.length === 0,
|
||||
};
|
||||
});
|
||||
await mkdir("artifacts/quality", { recursive: true });
|
||||
await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
await writeValidatedJsonArtifact({
|
||||
path: artifactPath,
|
||||
schema: optionalRecipesArtifactSchema,
|
||||
value: report,
|
||||
});
|
||||
|
||||
if (violations.length > 0) {
|
||||
process.stderr.write(
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { realtimeBoundariesArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||
import { scanRealtimeBoundaries } from "./lib/realtime-boundaries.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
const sourceRoot = argument("--source-root") ?? "src";
|
||||
const artifact =
|
||||
argument("--artifact") ??
|
||||
"artifacts/quality/realtime-boundaries.json";
|
||||
const violations = await scanRealtimeBoundaries(sourceRoot);
|
||||
const report = Object.freeze({
|
||||
const report = realtimeBoundariesArtifactSchema.parse({
|
||||
schemaVersion: 1,
|
||||
sourceRoot,
|
||||
violations,
|
||||
@@ -16,10 +18,11 @@ const report = Object.freeze({
|
||||
});
|
||||
|
||||
await mkdir(path.dirname(artifact), { recursive: true });
|
||||
await writeFile(
|
||||
artifact,
|
||||
`${JSON.stringify(report, null, 2)}\n`,
|
||||
);
|
||||
await writeValidatedJsonArtifact({
|
||||
path: artifact,
|
||||
schema: realtimeBoundariesArtifactSchema,
|
||||
value: report,
|
||||
});
|
||||
|
||||
if (violations.length > 0) {
|
||||
for (const violation of violations) {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
|
||||
import { registryCompatibilityFixturesArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||
import {
|
||||
diffRegistrySnapshots,
|
||||
validateBreakingEvidence,
|
||||
verifyRegistryBaselineApproval,
|
||||
} from "./lib/registry-compatibility.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
const fixtures = JSON.parse(
|
||||
await readFile(
|
||||
@@ -50,10 +52,11 @@ results.push({
|
||||
});
|
||||
|
||||
await mkdir("artifacts/quality", { recursive: true });
|
||||
await writeFile(
|
||||
"artifacts/quality/registry-compatibility-fixtures.json",
|
||||
`${JSON.stringify({ schemaVersion: 1, results }, null, 2)}\n`,
|
||||
);
|
||||
await writeValidatedJsonArtifact({
|
||||
path: "artifacts/quality/registry-compatibility-fixtures.json",
|
||||
schema: registryCompatibilityFixturesArtifactSchema,
|
||||
value: { schemaVersion: 1, results },
|
||||
});
|
||||
if (results.some((result) => !result.passed)) {
|
||||
process.stderr.write("Registry compatibility fixture failed.\n");
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
|
||||
import { supplyChainFixturesArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||
import {
|
||||
diffDependencyInventories,
|
||||
isValidSha512Integrity,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
validateVulnerabilityReport,
|
||||
verifySupplyChainCoherence,
|
||||
} from "./lib/supply-chain.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
const integrity = `sha512-${Buffer.alloc(64, 1).toString("base64")}`;
|
||||
const baseDependency = {
|
||||
@@ -137,10 +139,11 @@ const results = [
|
||||
},
|
||||
];
|
||||
await mkdir("artifacts/security", { recursive: true });
|
||||
await writeFile(
|
||||
"artifacts/security/supply-chain-fixtures.json",
|
||||
`${JSON.stringify({ schemaVersion: 1, results }, null, 2)}\n`,
|
||||
);
|
||||
await writeValidatedJsonArtifact({
|
||||
path: "artifacts/security/supply-chain-fixtures.json",
|
||||
schema: supplyChainFixturesArtifactSchema,
|
||||
value: { schemaVersion: 1, results },
|
||||
});
|
||||
if (results.some((result) => !result.passed)) {
|
||||
process.stderr.write("Supply-chain negative fixture failed.\n");
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,248 +1,161 @@
|
||||
import { createHash, generateKeyPairSync, sign } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
createHash,
|
||||
generateKeyPairSync,
|
||||
sign,
|
||||
type KeyObject,
|
||||
} from "node:crypto";
|
||||
import {
|
||||
cp,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
localEvidenceAssessmentArtifactSchema,
|
||||
supplyChainProviderFixturesArtifactSchema,
|
||||
} from "./contracts/release-artifacts.ts";
|
||||
import { PROMOTED_FILE_NAMES } from "./contracts/promotion-artifacts.ts";
|
||||
import {
|
||||
captureCiCandidateArchive,
|
||||
} from "./lib/ci-candidate-archive.ts";
|
||||
import {
|
||||
cleanupFinalizedPromotion,
|
||||
finalizeVerifiedPromotion,
|
||||
} from "./lib/promotion-stager.ts";
|
||||
import { verifyExactPromotionBundle } from "./lib/exact-promotion-bundle.ts";
|
||||
import {
|
||||
providerEvidenceSignaturePayload,
|
||||
providerPublicKeyFingerprint,
|
||||
} from "./lib/provider-evidence.ts";
|
||||
import { localEvidenceAssessmentArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||
import { verifyPromotionInputs } from "./lib/promotion-verifier.ts";
|
||||
import {
|
||||
createReleaseCandidateManifest,
|
||||
LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
releaseCandidateManifestSchema,
|
||||
} from "./lib/release-candidate.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
const NOW = Date.parse("2026-08-02T01:00:00.000Z");
|
||||
const FIXTURE_SOURCE = Object.freeze({
|
||||
revision: "a".repeat(40),
|
||||
sourceSetSha256: "b".repeat(64),
|
||||
});
|
||||
const FIXTURE_LOCAL_IDENTITY = Object.freeze({
|
||||
sourceRevision: FIXTURE_SOURCE.revision,
|
||||
sourceSetSha256: FIXTURE_SOURCE.sourceSetSha256,
|
||||
assessmentSha256: "c".repeat(64),
|
||||
});
|
||||
|
||||
const fixtureRoot = await mkdtemp(
|
||||
path.join(tmpdir(), "supply-chain-provider-fixture-"),
|
||||
);
|
||||
const repositoryRoot = process.cwd();
|
||||
const fixtureRoot = await mkdtemp(path.join(tmpdir(), "provider-exact-five-fixture-"));
|
||||
try {
|
||||
const repositoryRoot = process.cwd();
|
||||
const actualCandidate = releaseCandidateManifestSchema.parse(
|
||||
await cp(repositoryRoot, fixtureRoot, {
|
||||
recursive: true,
|
||||
filter: (source) => {
|
||||
const relative = path.relative(repositoryRoot, source);
|
||||
if (!relative) return true;
|
||||
const first = relative.split(path.sep)[0];
|
||||
return ![".release", "artifacts", "dist", "node_modules"].includes(first ?? "");
|
||||
},
|
||||
});
|
||||
await cp(path.join(repositoryRoot, "artifacts"), path.join(fixtureRoot, "artifacts"), {
|
||||
recursive: true,
|
||||
});
|
||||
await rm(path.join(fixtureRoot, "artifacts/release"), { recursive: true, force: true });
|
||||
await symlink(path.join(repositoryRoot, "node_modules"), path.join(fixtureRoot, "node_modules"), "dir");
|
||||
const git = spawnSync("git", ["show", "-s", "--format=%H%n%ct", "HEAD"], {
|
||||
cwd: repositoryRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (git.status !== 0) throw new Error(`provider fixture git identity failed: ${git.stderr}`);
|
||||
const [revision, sourceDateEpoch] = git.stdout.trim().split(/\r?\n/u);
|
||||
if (!revision || !sourceDateEpoch) throw new Error("provider fixture git identity is incomplete");
|
||||
const build = spawnSync("corepack", ["pnpm", "build:release-candidate"], {
|
||||
cwd: fixtureRoot,
|
||||
encoding: "utf8",
|
||||
timeout: 120_000,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
env: {
|
||||
...process.env,
|
||||
CI: "true",
|
||||
VITE_BUILD_ID: "provider-exact-five-fixture",
|
||||
VITE_COMMIT_SHA: revision,
|
||||
RELEASE_ID: "provider-exact-five-fixture",
|
||||
SOURCE_DATE_EPOCH: sourceDateEpoch,
|
||||
CI_RUNNER_IMAGE: `fixture@sha256:${"a".repeat(64)}`,
|
||||
},
|
||||
});
|
||||
if (build.status !== 0) throw new Error(`${build.stdout}\n${build.stderr}`);
|
||||
const manifest = releaseCandidateManifestSchema.parse(
|
||||
JSON.parse(
|
||||
await readFile(
|
||||
path.join(repositoryRoot, RELEASE_CANDIDATE_MANIFEST_PATH),
|
||||
"utf8",
|
||||
),
|
||||
await readFile(path.join(fixtureRoot, RELEASE_CANDIDATE_MANIFEST_PATH), "utf8"),
|
||||
) as unknown,
|
||||
);
|
||||
const actualAssessment = localEvidenceAssessmentArtifactSchema.parse(
|
||||
const assessment = localEvidenceAssessmentArtifactSchema.parse(
|
||||
JSON.parse(
|
||||
await readFile(path.join(repositoryRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH), "utf8"),
|
||||
await readFile(path.join(fixtureRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH), "utf8"),
|
||||
) as unknown,
|
||||
);
|
||||
const actualProviderEnvironment = absoluteProviderEnvironment(
|
||||
fixtureRoot,
|
||||
await writeProviderEnvironment(
|
||||
fixtureRoot,
|
||||
"actual",
|
||||
actualCandidate,
|
||||
{
|
||||
revision: actualAssessment.source.revision,
|
||||
sourceSetSha256: actualAssessment.source.sourceSetSha256,
|
||||
},
|
||||
),
|
||||
const archivePath = path.join(fixtureRoot, "candidate.tar.gz");
|
||||
const tar = spawnSync(
|
||||
"/usr/bin/tar",
|
||||
[
|
||||
"--sort=name",
|
||||
"--mtime=@0",
|
||||
"--owner=0",
|
||||
"--group=0",
|
||||
"--numeric-owner",
|
||||
"-czf",
|
||||
archivePath,
|
||||
"dist",
|
||||
...RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
],
|
||||
{ cwd: fixtureRoot, encoding: "utf8" },
|
||||
);
|
||||
const actualDefaultVerifier = await verifyPromotionInputs({
|
||||
artifactType: "provider-verification",
|
||||
environment: actualProviderEnvironment,
|
||||
nowEpochMs: () => NOW,
|
||||
});
|
||||
|
||||
const rawLockfile = "lockfileVersion: '9.0'\n";
|
||||
const lockfileSha256 = createHash("sha256")
|
||||
.update(rawLockfile)
|
||||
.digest("hex");
|
||||
await mkdir(path.join(fixtureRoot, "dist"), { recursive: true });
|
||||
await writeFile(path.join(fixtureRoot, "dist/app.js"), "immutable\n");
|
||||
await writeFile(path.join(fixtureRoot, "pnpm-lock.yaml"), rawLockfile);
|
||||
for (const file of RELEASE_CANDIDATE_EVIDENCE_PATHS) {
|
||||
if (file === "pnpm-lock.yaml") continue;
|
||||
await mkdir(path.dirname(path.join(fixtureRoot, file)), {
|
||||
recursive: true,
|
||||
});
|
||||
const value =
|
||||
file === "artifacts/release/dependency-inventory.json"
|
||||
? { lockfileSha256 }
|
||||
: { fixture: file };
|
||||
await writeFile(
|
||||
path.join(fixtureRoot, file),
|
||||
`${JSON.stringify(value)}\n`,
|
||||
);
|
||||
}
|
||||
const candidate = await createReleaseCandidateManifest(fixtureRoot);
|
||||
await writeFile(
|
||||
path.join(fixtureRoot, RELEASE_CANDIDATE_MANIFEST_PATH),
|
||||
`${JSON.stringify(candidate)}\n`,
|
||||
);
|
||||
|
||||
const validEnvironment = await writeProviderEnvironment(
|
||||
fixtureRoot,
|
||||
"valid",
|
||||
candidate,
|
||||
FIXTURE_SOURCE,
|
||||
);
|
||||
const wrongEnvironment = await writeProviderEnvironment(
|
||||
fixtureRoot,
|
||||
"wrong",
|
||||
candidate,
|
||||
FIXTURE_SOURCE,
|
||||
{ distSha256: "3".repeat(64) },
|
||||
);
|
||||
const acceptLocalEvidence = async () => ({
|
||||
status: "PASS" as const,
|
||||
identity: FIXTURE_LOCAL_IDENTITY,
|
||||
failures: [] as const,
|
||||
});
|
||||
const fixtures = {
|
||||
absent: await verifyPromotionInputs({
|
||||
artifactType: "provider-verification",
|
||||
repositoryRoot: fixtureRoot,
|
||||
environment: {},
|
||||
verifyLocalEvidence: acceptLocalEvidence,
|
||||
nowEpochMs: () => NOW,
|
||||
}),
|
||||
validImmutable: await verifyPromotionInputs({
|
||||
artifactType: "provider-verification",
|
||||
repositoryRoot: fixtureRoot,
|
||||
environment: validEnvironment,
|
||||
verifyLocalEvidence: acceptLocalEvidence,
|
||||
nowEpochMs: () => NOW,
|
||||
}),
|
||||
wrongDigest: await verifyPromotionInputs({
|
||||
artifactType: "provider-verification",
|
||||
repositoryRoot: fixtureRoot,
|
||||
environment: wrongEnvironment,
|
||||
verifyLocalEvidence: acceptLocalEvidence,
|
||||
nowEpochMs: () => NOW,
|
||||
}),
|
||||
postAttestationMutation: null as Awaited<
|
||||
ReturnType<typeof verifyPromotionInputs>
|
||||
> | null,
|
||||
};
|
||||
await writeFile(path.join(fixtureRoot, "dist/app.js"), "mutated\n");
|
||||
fixtures.postAttestationMutation = await verifyPromotionInputs({
|
||||
artifactType: "provider-verification",
|
||||
repositoryRoot: fixtureRoot,
|
||||
environment: validEnvironment,
|
||||
verifyLocalEvidence: acceptLocalEvidence,
|
||||
nowEpochMs: () => NOW,
|
||||
});
|
||||
|
||||
const passed =
|
||||
actualDefaultVerifier.status === "PASS" &&
|
||||
fixtures.validImmutable.status === "PASS" &&
|
||||
fixtures.absent.status === "FAIL_UNVERIFIED" &&
|
||||
fixtures.wrongDigest.status === "FAIL_UNVERIFIED" &&
|
||||
fixtures.postAttestationMutation.status === "FAIL_UNVERIFIED";
|
||||
await mkdir("artifacts/security", { recursive: true });
|
||||
await writeFile(
|
||||
"artifacts/security/supply-chain-provider-fixtures.json",
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
actualDefaultVerifier: {
|
||||
status: actualDefaultVerifier.status,
|
||||
failures: actualDefaultVerifier.failures,
|
||||
},
|
||||
fixtures: Object.fromEntries(
|
||||
Object.entries(fixtures).map(([name, result]) => [
|
||||
name,
|
||||
{ status: result?.status, failures: result?.failures },
|
||||
]),
|
||||
),
|
||||
passingFixtureCount: Object.values(fixtures).filter(
|
||||
(result) => result?.status === "PASS",
|
||||
).length,
|
||||
status: passed ? "PASS" : "FAIL",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
if (!passed) {
|
||||
process.stderr.write(
|
||||
"Supply-chain provider fixtures failed closed incorrectly\n",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
process.stdout.write(
|
||||
"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 [
|
||||
"CANDIDATE_ARCHIVE_PATH",
|
||||
"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,
|
||||
candidate: Awaited<ReturnType<typeof createReleaseCandidateManifest>>,
|
||||
source: Readonly<{ revision: string; sourceSetSha256: string }>,
|
||||
overrides: Readonly<{ distSha256?: string }> = {},
|
||||
): Promise<NodeJS.ProcessEnv> {
|
||||
if (tar.status !== 0) throw new Error(`provider fixture real tar failed: ${tar.stderr}`);
|
||||
const archiveBytes = await readFile(archivePath);
|
||||
const archiveSha256 = sha256(archiveBytes);
|
||||
const now = Date.parse("2026-08-02T01:00:00.000Z");
|
||||
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
||||
const provenanceKeys = generateKeyPairSync("ed25519");
|
||||
const directory = `provider/${name}`;
|
||||
const archiveBytes = `fixture archive ${name}\n`;
|
||||
const archiveSha256 = createHash("sha256").update(archiveBytes).digest("hex");
|
||||
const candidateIdentity = {
|
||||
archiveSha256,
|
||||
bundleSha256: candidate.bundleSha256,
|
||||
distSha256: overrides.distSha256 ?? candidate.distSha256,
|
||||
lockfileSha256: candidate.lockfileSha256,
|
||||
};
|
||||
const sourceIdentity = {
|
||||
revision: source.revision,
|
||||
sourceSetSha256: source.sourceSetSha256,
|
||||
};
|
||||
await mkdir(path.join(repositoryRoot, directory), { recursive: true });
|
||||
const vulnerabilityKeyId = "fixture-vulnerability-key";
|
||||
const provenanceKeyId = "fixture-provenance-key";
|
||||
const vulnerabilityNonce = "1".repeat(64);
|
||||
const provenanceNonce = "2".repeat(64);
|
||||
const context = {
|
||||
run: { id: "fixture-run", attempt: 1 },
|
||||
source: {
|
||||
revision: assessment.source.revision,
|
||||
sourceSetSha256: assessment.source.sourceSetSha256,
|
||||
},
|
||||
candidate: {
|
||||
archiveSha256,
|
||||
bundleSha256: manifest.bundleSha256,
|
||||
distSha256: manifest.distSha256,
|
||||
lockfileSha256: manifest.lockfileSha256,
|
||||
},
|
||||
} as const;
|
||||
const vulnerability = signedEvidence(
|
||||
{
|
||||
schemaVersion: 2,
|
||||
evidenceType: "vulnerability-report",
|
||||
provider: "fixture-vulnerability-provider",
|
||||
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||
run: { id: "fixture-run", attempt: 1, invocationNonce: "1".repeat(64) },
|
||||
source: sourceIdentity,
|
||||
candidate: candidateIdentity,
|
||||
issuedAt: new Date(now).toISOString(),
|
||||
expiresAt: new Date(now + 60 * 60 * 1_000).toISOString(),
|
||||
run: { ...context.run, invocationNonce: vulnerabilityNonce },
|
||||
source: context.source,
|
||||
candidate: context.candidate,
|
||||
secretScanAttestation: {
|
||||
status: "PASS",
|
||||
localEvidenceAssessmentSha256: sha256(
|
||||
await readFile(path.join(fixtureRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH)),
|
||||
),
|
||||
sourceSetSha256: assessment.source.sourceSetSha256,
|
||||
policySha256: assessment.secretScan.policySha256,
|
||||
sarifSha256: assessment.secretScan.sarifSha256,
|
||||
scanInputSha256: assessment.secretScan.scanInputSha256,
|
||||
},
|
||||
findings: [],
|
||||
},
|
||||
"fixture-vulnerability-key",
|
||||
vulnerabilityKeyId,
|
||||
vulnerabilityKeys.publicKey,
|
||||
vulnerabilityKeys.privateKey,
|
||||
);
|
||||
@@ -252,65 +165,175 @@ async function writeProviderEnvironment(
|
||||
evidenceType: "provenance-attestation",
|
||||
provider: "fixture-provenance-provider",
|
||||
signer: "fixture-workload-identity",
|
||||
issuedAt: "2026-08-02T01:00:00.000Z",
|
||||
expiresAt: "2026-08-02T02:00:00.000Z",
|
||||
run: { id: "fixture-run", attempt: 1, invocationNonce: "2".repeat(64) },
|
||||
source: sourceIdentity,
|
||||
candidate: candidateIdentity,
|
||||
subject: { name: "dist", digest: { sha256: candidateIdentity.distSha256 } },
|
||||
issuedAt: new Date(now).toISOString(),
|
||||
expiresAt: new Date(now + 60 * 60 * 1_000).toISOString(),
|
||||
run: { ...context.run, invocationNonce: provenanceNonce },
|
||||
source: context.source,
|
||||
candidate: context.candidate,
|
||||
subject: { name: "dist", digest: { sha256: manifest.distSha256 } },
|
||||
},
|
||||
"fixture-provenance-key",
|
||||
provenanceKeyId,
|
||||
provenanceKeys.publicKey,
|
||||
provenanceKeys.privateKey,
|
||||
);
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(repositoryRoot, directory, "candidate.tar.gz"),
|
||||
archiveBytes,
|
||||
write(fixtureRoot, "provider/vulnerability.json", `${JSON.stringify(vulnerability)}\n`),
|
||||
write(fixtureRoot, "provider/provenance.json", `${JSON.stringify(provenance)}\n`),
|
||||
write(
|
||||
fixtureRoot,
|
||||
"provider/vulnerability.pem",
|
||||
vulnerabilityKeys.publicKey.export({ type: "spki", format: "pem" }),
|
||||
),
|
||||
writeFile(
|
||||
path.join(repositoryRoot, directory, "vulnerability.json"),
|
||||
`${JSON.stringify(vulnerability)}\n`,
|
||||
),
|
||||
writeFile(
|
||||
path.join(repositoryRoot, directory, "provenance.json"),
|
||||
`${JSON.stringify(provenance)}\n`,
|
||||
),
|
||||
writeFile(
|
||||
path.join(repositoryRoot, directory, "vulnerability.pem"),
|
||||
vulnerabilityKeys.publicKey
|
||||
.export({ type: "spki", format: "pem" })
|
||||
.toString(),
|
||||
),
|
||||
writeFile(
|
||||
path.join(repositoryRoot, directory, "provenance.pem"),
|
||||
provenanceKeys.publicKey
|
||||
.export({ type: "spki", format: "pem" })
|
||||
.toString(),
|
||||
write(
|
||||
fixtureRoot,
|
||||
"provider/provenance.pem",
|
||||
provenanceKeys.publicKey.export({ type: "spki", format: "pem" }),
|
||||
),
|
||||
]);
|
||||
return {
|
||||
CANDIDATE_ARCHIVE_PATH: `${directory}/candidate.tar.gz`,
|
||||
CANDIDATE_ARCHIVE_SHA256: archiveSha256,
|
||||
CI_RUN_ID: "fixture-run",
|
||||
CI_RUN_ATTEMPT: "1",
|
||||
EXPECTED_SOURCE_REVISION: source.revision,
|
||||
VULNERABILITY_INVOCATION_NONCE: "1".repeat(64),
|
||||
PROVENANCE_INVOCATION_NONCE: "2".repeat(64),
|
||||
VULNERABILITY_REPORT_PATH: `${directory}/vulnerability.json`,
|
||||
PROVENANCE_ATTESTATION_PATH: `${directory}/provenance.json`,
|
||||
VULNERABILITY_PUBLIC_KEY_PATH: `${directory}/vulnerability.pem`,
|
||||
VULNERABILITY_KEY_ID: "fixture-vulnerability-key",
|
||||
PROVENANCE_PUBLIC_KEY_PATH: `${directory}/provenance.pem`,
|
||||
PROVENANCE_KEY_ID: "fixture-provenance-key",
|
||||
};
|
||||
const runnerTempRoot = path.join(fixtureRoot, "runner-temp");
|
||||
await mkdir(runnerTempRoot, { mode: 0o700 });
|
||||
const finalized = await finalizeVerifiedPromotion(
|
||||
{
|
||||
repositoryRoot: fixtureRoot,
|
||||
archivePath,
|
||||
expectedArchiveSha256: archiveSha256,
|
||||
vulnerabilityReportPath: "provider/vulnerability.json",
|
||||
provenanceAttestationPath: "provider/provenance.json",
|
||||
vulnerabilityPublicKeyPath: "provider/vulnerability.pem",
|
||||
vulnerabilityKeyId,
|
||||
provenancePublicKeyPath: "provider/provenance.pem",
|
||||
provenanceKeyId,
|
||||
expectedRun: { id: context.run.id, attempt: 1, sourceRevision: revision },
|
||||
vulnerabilityInvocationNonce: vulnerabilityNonce,
|
||||
provenanceInvocationNonce: provenanceNonce,
|
||||
runnerTempRoot,
|
||||
},
|
||||
{
|
||||
nowEpochMs: () => now,
|
||||
randomBytes: (bytes) => Buffer.alloc(bytes, 0x4a),
|
||||
afterCapture: async () => {
|
||||
await writeFile(path.join(fixtureRoot, "dist/index.html"), "contradictory external tree\n");
|
||||
},
|
||||
},
|
||||
);
|
||||
const validImmutable =
|
||||
finalized.files.length === 5 &&
|
||||
(await readdir(finalized.stagingRoot)).length === 5;
|
||||
const exactFiles = Object.fromEntries(
|
||||
await Promise.all(
|
||||
PROMOTED_FILE_NAMES.map(async (name) => [
|
||||
name,
|
||||
await readFile(path.join(finalized.stagingRoot, name)),
|
||||
] as const),
|
||||
),
|
||||
);
|
||||
const bundleTrust = {
|
||||
vulnerabilityTrust: {
|
||||
keyId: vulnerabilityKeyId,
|
||||
publicKey: vulnerabilityKeys.publicKey,
|
||||
publicKeyFingerprint: providerPublicKeyFingerprint(vulnerabilityKeys.publicKey),
|
||||
},
|
||||
provenanceTrust: {
|
||||
keyId: provenanceKeyId,
|
||||
publicKey: provenanceKeys.publicKey,
|
||||
publicKeyFingerprint: providerPublicKeyFingerprint(provenanceKeys.publicKey),
|
||||
},
|
||||
expected: {
|
||||
run: context.run,
|
||||
sourceRevision: context.source.revision,
|
||||
sourceSetSha256: context.source.sourceSetSha256,
|
||||
archiveSha256: context.candidate.archiveSha256,
|
||||
bundleSha256: context.candidate.bundleSha256,
|
||||
distSha256: context.candidate.distSha256,
|
||||
lockfileSha256: context.candidate.lockfileSha256,
|
||||
},
|
||||
nowEpochMs: () => now,
|
||||
} as const;
|
||||
const absentFiles = { ...exactFiles } as Partial<typeof exactFiles>;
|
||||
delete absentFiles["provider-verification.json"];
|
||||
const absent = await captureRejection(async () => {
|
||||
await verifyExactPromotionBundle(absentFiles, bundleTrust);
|
||||
});
|
||||
|
||||
const invalidPath = path.join(fixtureRoot, "invalid.tar.gz");
|
||||
const invalidBytes = Buffer.from("arbitrary non-tar bytes\n");
|
||||
await writeFile(invalidPath, invalidBytes);
|
||||
const invalidTar = await captureRejection(async () => {
|
||||
await finalizeVerifiedPromotion(
|
||||
{
|
||||
repositoryRoot: fixtureRoot,
|
||||
archivePath: invalidPath,
|
||||
expectedArchiveSha256: sha256(invalidBytes),
|
||||
vulnerabilityReportPath: "provider/vulnerability.json",
|
||||
provenanceAttestationPath: "provider/provenance.json",
|
||||
vulnerabilityPublicKeyPath: "provider/vulnerability.pem",
|
||||
vulnerabilityKeyId,
|
||||
provenancePublicKeyPath: "provider/provenance.pem",
|
||||
provenanceKeyId,
|
||||
expectedRun: { id: context.run.id, attempt: 1, sourceRevision: revision },
|
||||
vulnerabilityInvocationNonce: vulnerabilityNonce,
|
||||
provenanceInvocationNonce: provenanceNonce,
|
||||
runnerTempRoot,
|
||||
},
|
||||
{ nowEpochMs: () => now },
|
||||
);
|
||||
});
|
||||
const wrongDigest = await captureRejection(async () => {
|
||||
await captureCiCandidateArchive({ archivePath, expectedSha256: "0".repeat(64) });
|
||||
});
|
||||
const passed =
|
||||
validImmutable && absent.rejected && invalidTar.rejected && wrongDigest.rejected;
|
||||
await cleanupFinalizedPromotion({
|
||||
runnerTempRoot,
|
||||
stagingRoot: finalized.stagingRoot,
|
||||
cleanupToken: finalized.cleanupToken,
|
||||
runnerTempIdentity: finalized.runnerTempIdentity,
|
||||
stagingIdentity: finalized.stagingIdentity,
|
||||
});
|
||||
await mkdir(path.join(repositoryRoot, "artifacts/security"), { recursive: true });
|
||||
await writeValidatedJsonArtifact({
|
||||
path: path.join(repositoryRoot, "artifacts/security/supply-chain-provider-fixtures.json"),
|
||||
schema: supplyChainProviderFixturesArtifactSchema,
|
||||
value: {
|
||||
schemaVersion: 1,
|
||||
actualDefaultVerifier: {
|
||||
status: validImmutable ? "PASS" : "FAIL_UNVERIFIED",
|
||||
failures: validImmutable ? [] : ["canonical exact-five finalizer failed"],
|
||||
},
|
||||
fixtures: {
|
||||
absent: {
|
||||
status: absent.rejected ? "FAIL_UNVERIFIED" : "PASS",
|
||||
failures: [absent.failure],
|
||||
},
|
||||
validImmutable: { status: validImmutable ? "PASS" : "FAIL_UNVERIFIED", failures: [] },
|
||||
wrongDigest: {
|
||||
status: wrongDigest.rejected ? "FAIL_UNVERIFIED" : "PASS",
|
||||
failures: [wrongDigest.failure],
|
||||
},
|
||||
invalidTar: {
|
||||
status: invalidTar.rejected ? "FAIL_UNVERIFIED" : "PASS",
|
||||
failures: [invalidTar.failure],
|
||||
},
|
||||
},
|
||||
externalTreeCanary: {
|
||||
status: validImmutable ? "PASS" : "FAIL_UNVERIFIED",
|
||||
failures: validImmutable ? [] : ["external tree canary influenced captured archive"],
|
||||
},
|
||||
passingFixtureCount: validImmutable ? 1 : 0,
|
||||
status: passed ? "PASS" : "FAIL",
|
||||
},
|
||||
});
|
||||
if (!passed) throw new Error("canonical exact-five provider fixtures failed closed incorrectly");
|
||||
process.stdout.write("Supply-chain provider real-tar signed exact-five fixtures: PASS\n");
|
||||
} finally {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function signedEvidence(
|
||||
value: Record<string, unknown>,
|
||||
keyId: string,
|
||||
publicKey: ReturnType<typeof generateKeyPairSync>["publicKey"],
|
||||
privateKey: ReturnType<typeof generateKeyPairSync>["privateKey"],
|
||||
publicKey: KeyObject,
|
||||
privateKey: KeyObject,
|
||||
) {
|
||||
return {
|
||||
...value,
|
||||
@@ -326,3 +349,27 @@ function signedEvidence(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function write(root: string, relative: string, value: string | Buffer): Promise<void> {
|
||||
const absolute = path.join(root, relative);
|
||||
await mkdir(path.dirname(absolute), { recursive: true });
|
||||
await writeFile(absolute, value);
|
||||
}
|
||||
|
||||
async function captureRejection(
|
||||
operation: () => Promise<void>,
|
||||
): Promise<Readonly<{ rejected: boolean; failure: string }>> {
|
||||
try {
|
||||
await operation();
|
||||
return Object.freeze({ rejected: false, failure: "fixture unexpectedly passed" });
|
||||
} catch (error) {
|
||||
return Object.freeze({
|
||||
rejected: true,
|
||||
failure: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(bytes: Buffer): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
@@ -21,5 +21,9 @@ await cleanupFinalizedPromotion({
|
||||
dev: requiredIdentity("PROMOTION_RUNNER_TEMP_DEV"),
|
||||
ino: requiredIdentity("PROMOTION_RUNNER_TEMP_INO"),
|
||||
},
|
||||
stagingIdentity: {
|
||||
dev: requiredIdentity("PROMOTION_STAGING_DEV"),
|
||||
ino: requiredIdentity("PROMOTION_STAGING_INO"),
|
||||
},
|
||||
});
|
||||
process.stdout.write("Promotion staging cleanup: PASS\n");
|
||||
|
||||
+123
-17
@@ -9,7 +9,11 @@ import {
|
||||
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
} from "../lib/release-candidate.ts";
|
||||
import { validatePackageScriptGraph } from "../lib/package-script-graph.ts";
|
||||
import {
|
||||
validateInstallScriptPolicy,
|
||||
validateNpmScopeEnvironment,
|
||||
validatePackageScriptGraph,
|
||||
} from "../lib/package-script-graph.ts";
|
||||
import { PROMOTED_UPLOAD_PATHS } from "./promotion-artifacts.ts";
|
||||
|
||||
const ciActionRegistrationSchema = z
|
||||
@@ -201,7 +205,22 @@ const artifactSchemaSchema = z.discriminatedUnion("kind", [
|
||||
kind: z.literal("json"),
|
||||
maxBytes: z.number().int().min(1).max(268_435_456),
|
||||
executableSchemaId: z.enum([
|
||||
"generic-json-object",
|
||||
"automated-a11y",
|
||||
"manual-a11y",
|
||||
"architecture-dependency-report",
|
||||
"design-system-contract",
|
||||
"i18n-contract",
|
||||
"diagnostics-contract",
|
||||
"realtime-boundaries",
|
||||
"optional-recipes",
|
||||
"optional-recipe-fixtures",
|
||||
"registry-compatibility-fixtures",
|
||||
"reproducible-build",
|
||||
"supply-chain-fixtures",
|
||||
"supply-chain-provider-fixtures",
|
||||
"compatibility-fixtures",
|
||||
"documentation-review",
|
||||
"hosting-headers",
|
||||
"coverage-summary-v8",
|
||||
"risk-coverage-v3",
|
||||
"build-manifest",
|
||||
@@ -318,12 +337,6 @@ const uploadStep = z
|
||||
const downloadStep = z
|
||||
.object({ kind: z.literal("download"), transferId: id, path: workflowPath })
|
||||
.strict();
|
||||
const validateCandidateArchiveStep = z
|
||||
.object({
|
||||
kind: z.literal("validate-candidate-archive"),
|
||||
archivePath: workflowPath,
|
||||
})
|
||||
.strict();
|
||||
const extractStep = z
|
||||
.object({
|
||||
kind: z.literal("extract"),
|
||||
@@ -360,7 +373,6 @@ const jobStepSchema = z.discriminatedUnion("kind", [
|
||||
archiveCandidateStep,
|
||||
uploadStep,
|
||||
downloadStep,
|
||||
validateCandidateArchiveStep,
|
||||
extractStep,
|
||||
providerStep,
|
||||
validateProviderStep,
|
||||
@@ -374,7 +386,7 @@ const jobSchema = z
|
||||
displayName: workflowValue,
|
||||
kind: z.enum(["gate-matrix", "gate-single", "immutable", "provider", "promotion"]),
|
||||
needs: z.array(id).max(32),
|
||||
condition: z.enum(["always", "merge", "release", "production", "field", "documentation"]),
|
||||
condition: z.enum(["always", "needs-success", "merge", "release", "production", "field", "documentation"]),
|
||||
timeoutMinutes: z.number().int().positive(),
|
||||
gateIds: z.array(id).max(64),
|
||||
browserGateIds: z.array(id).max(64),
|
||||
@@ -455,7 +467,43 @@ function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
|
||||
return createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
|
||||
}
|
||||
|
||||
export function parseCiGateContract(value: unknown): CiGateContract {
|
||||
function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[] {
|
||||
const failures: string[] = [];
|
||||
const commandReferenceCount = contract.gates.reduce(
|
||||
(total, gate) => total + gate.commandIds.length,
|
||||
0,
|
||||
);
|
||||
if (contract.gates.length !== 26) {
|
||||
failures.push(`gate authority baseline must contain exactly 26 gates; received ${contract.gates.length}`);
|
||||
}
|
||||
if (contract.commands.length !== 81 || commandReferenceCount !== 93) {
|
||||
failures.push(
|
||||
`command authority baseline must contain exactly 81 definitions and 93 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`,
|
||||
);
|
||||
}
|
||||
if (contract.artifacts.length !== 105) {
|
||||
failures.push(`artifact authority baseline must contain exactly 105 artifacts; received ${contract.artifacts.length}`);
|
||||
}
|
||||
if (contract.stages.length !== 5) {
|
||||
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
|
||||
}
|
||||
const expectedRetentionClasses = [
|
||||
{ id: "merge-cycle", policy: "at least through pull-request readiness decision" },
|
||||
{ id: "release-coherence", policy: "at least until the next release is promoted" },
|
||||
{ id: "prod-drill", policy: "at least until the next production promotion decision" },
|
||||
{ id: "field", policy: "through the 28-day window and aggregation" },
|
||||
{ id: "documentation", policy: "through documentation readiness review" },
|
||||
];
|
||||
if (JSON.stringify(contract.retention.classes) !== JSON.stringify(expectedRetentionClasses)) {
|
||||
failures.push("retention registry must contain exactly the five canonical retention classes");
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
export function parseCiGateContract(
|
||||
value: unknown,
|
||||
options: LoadCiGateContractOptions = {},
|
||||
): CiGateContract {
|
||||
const result = ciGateContractSchema.safeParse(value);
|
||||
if (!result.success) {
|
||||
const diagnostic = result.error.issues
|
||||
@@ -463,6 +511,12 @@ export function parseCiGateContract(value: unknown): CiGateContract {
|
||||
.join("\n");
|
||||
throw new TypeError(`CI gate contract invalid:\n${diagnostic}`);
|
||||
}
|
||||
if ((options.mode ?? "canonical") === "canonical") {
|
||||
const failures = canonicalAuthorityBaselineFailures(result.data);
|
||||
if (failures.length > 0) {
|
||||
throw new TypeError(`CI gate contract invalid:\n${failures.map((failure) => `root: ${failure}`).join("\n")}`);
|
||||
}
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@@ -470,12 +524,12 @@ export async function loadCiGateContract(
|
||||
root = process.cwd(),
|
||||
options: LoadCiGateContractOptions = {},
|
||||
): Promise<CiGateContract> {
|
||||
const mode = options.mode ?? "canonical";
|
||||
const [rawContract, rawPackage] = await Promise.all([
|
||||
readFile(path.join(root, "config/ci/gates.json"), "utf8"),
|
||||
readFile(path.join(root, "package.json"), "utf8"),
|
||||
]);
|
||||
const contract = parseCiGateContract(JSON.parse(rawContract));
|
||||
const mode = options.mode ?? "canonical";
|
||||
const contract = parseCiGateContract(JSON.parse(rawContract), { mode });
|
||||
if (
|
||||
mode === "canonical" &&
|
||||
canonicalGateShapeSha256(contract.gates) !== CANONICAL_GATE_SHAPE_SHA256
|
||||
@@ -510,13 +564,57 @@ export async function loadCiGateContract(
|
||||
throw new TypeError(`canonical check:ci dependency drift: ${script}`);
|
||||
}
|
||||
}
|
||||
const graphFailures = validatePackageScriptGraph(packageDocument.scripts, "check:ci");
|
||||
const contractEntryScripts = [
|
||||
...new Set(contract.commands.map((command) => command.script)),
|
||||
];
|
||||
const graphFailures = [
|
||||
...validatePackageScriptGraph(packageDocument.scripts, "check:ci"),
|
||||
...contractEntryScripts.flatMap((script) =>
|
||||
validatePackageScriptGraph(packageDocument.scripts, script)
|
||||
),
|
||||
];
|
||||
if (graphFailures.length > 0) {
|
||||
throw new TypeError(`CI package script graph invalid:\n${graphFailures.join("\n")}`);
|
||||
throw new TypeError(
|
||||
`CI package script graph invalid:\n${[...new Set(graphFailures)].join("\n")}`,
|
||||
);
|
||||
}
|
||||
const installPolicyFailures = validateInstallScriptPolicy(
|
||||
packageDocument.scripts,
|
||||
contractEntryScripts,
|
||||
);
|
||||
if (installPolicyFailures.length > 0) {
|
||||
throw new TypeError(
|
||||
`CI package script install policy invalid:\n${installPolicyFailures.join("\n")}`,
|
||||
);
|
||||
}
|
||||
return contract;
|
||||
}
|
||||
|
||||
export async function withCiGatePreflight<Result>(
|
||||
root: string,
|
||||
gateId: string | undefined,
|
||||
execute: (context: Readonly<{
|
||||
contract: CiGateContract;
|
||||
contractIndex: CiGateContractIndex;
|
||||
gateId: string;
|
||||
gate: CiGate;
|
||||
}>) => Promise<Result> | Result,
|
||||
): Promise<Result> {
|
||||
const npmScopeEnvironmentFailures = validateNpmScopeEnvironment(process.env);
|
||||
if (npmScopeEnvironmentFailures.length > 0) {
|
||||
throw new TypeError(
|
||||
`CI runner npm scope environment invalid:\n${npmScopeEnvironmentFailures.join("\n")}`,
|
||||
);
|
||||
}
|
||||
const contract = await loadCiGateContract(root);
|
||||
const contractIndex = indexCiGateContract(contract);
|
||||
const gate = gateId ? contractIndex.gates.get(gateId) : undefined;
|
||||
if (!gateId || !gate) {
|
||||
throw new TypeError("CI gate id must be FE-GATE-001..FE-GATE-026");
|
||||
}
|
||||
return execute({ contract, contractIndex, gateId, gate });
|
||||
}
|
||||
|
||||
export function indexCiGateContract(contract: CiGateContract): CiGateContractIndex {
|
||||
return Object.freeze({
|
||||
commands: new Map(contract.commands.map((entry) => [entry.id, entry])),
|
||||
@@ -625,6 +723,14 @@ function validateContractSemantics(
|
||||
issue(`unknown retention class ${gate.retentionClassId} for ${gate.id}`);
|
||||
}
|
||||
}
|
||||
const referencedRetentionClasses = new Set(
|
||||
contract.gates.map(({ retentionClassId }) => retentionClassId),
|
||||
);
|
||||
for (const retentionClass of contract.retention.classes) {
|
||||
if (!referencedRetentionClasses.has(retentionClass.id)) {
|
||||
issue(`orphan retention class: ${retentionClass.id}`);
|
||||
}
|
||||
}
|
||||
const referencedCommands = new Set(contract.gates.flatMap(({ commandIds }) => commandIds));
|
||||
for (const command of contract.commands) {
|
||||
if (!referencedCommands.has(command.id)) issue(`orphan command: ${command.id}`);
|
||||
@@ -751,7 +857,7 @@ function validateContractSemantics(
|
||||
immutable_build: ["immutable", ["release_gate"], "release"],
|
||||
vulnerability_provider: ["provider", ["immutable_build"], "always"],
|
||||
provenance_provider: ["provider", ["immutable_build"], "always"],
|
||||
promotion: ["promotion", ["immutable_build", "vulnerability_provider", "provenance_provider"], "always"],
|
||||
promotion: ["promotion", ["immutable_build", "vulnerability_provider", "provenance_provider"], "needs-success"],
|
||||
production_gate: ["gate-matrix", ["promotion"], "production"],
|
||||
field_gate: ["gate-single", ["production_gate"], "field"],
|
||||
documentation_gate: ["gate-single", [], "documentation"],
|
||||
@@ -1026,7 +1132,7 @@ function validateJobStepKinds(
|
||||
"gate-matrix": new Set(["checkout", "setup-node", "frozen-install", "browser-install", "run-gate", "upload"]),
|
||||
"gate-single": new Set(["checkout", "setup-node", "frozen-install", "run-gate", "upload"]),
|
||||
immutable: new Set(["checkout", "setup-node", "frozen-install", "run-gate", "archive-candidate", "upload"]),
|
||||
provider: new Set(["checkout", "setup-node", "frozen-install", "download", "validate-candidate-archive", "extract", "run-provider", "validate-provider-evidence", "upload"]),
|
||||
provider: new Set(["checkout", "setup-node", "frozen-install", "download", "extract", "run-provider", "validate-provider-evidence", "upload"]),
|
||||
promotion: new Set(["checkout", "setup-node", "frozen-install", "download", "verify-promotion", "upload", "cleanup-promotion"]),
|
||||
};
|
||||
for (const step of job.steps) {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { z } from "zod";
|
||||
|
||||
export * from "../../src/contracts/release-artifacts.ts";
|
||||
|
||||
import { MANUAL_A11Y_ROUTE_IDS } from "../lib/manual-a11y-evidence.ts";
|
||||
|
||||
const nonEmptyString = z.string().min(1);
|
||||
const timestamp = z.iso.datetime();
|
||||
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
@@ -85,6 +87,13 @@ export const localEvidenceAssessmentArtifactSchema = z
|
||||
candidate: z
|
||||
.object({ distSha256: sha256, lockfileSha256: sha256, sbomSha256: sha256 })
|
||||
.strict(),
|
||||
secretScan: z
|
||||
.object({
|
||||
policySha256: sha256,
|
||||
sarifSha256: sha256,
|
||||
scanInputSha256: sha256,
|
||||
})
|
||||
.strict(),
|
||||
policyInputs: z.array(assessmentInputRowSchema).min(1).max(256),
|
||||
evidenceInputs: z.array(assessmentInputRowSchema).min(1).max(4_096),
|
||||
checks: z
|
||||
@@ -308,20 +317,162 @@ const outputDigestSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const bundlePerformanceArtifactSchema = z
|
||||
const bundleOutputInventoryShape = {
|
||||
schemaVersion: z.literal(1),
|
||||
generatedAt: timestamp,
|
||||
context: z
|
||||
.object({
|
||||
nodeVersion: nonEmptyString,
|
||||
packageManager: nonEmptyString,
|
||||
runnerImage: nonEmptyString,
|
||||
})
|
||||
.strict(),
|
||||
outputs: z.array(outputDigestSchema).min(1),
|
||||
} as const;
|
||||
|
||||
function addUniqueBundleOutputIssues(
|
||||
artifact: Readonly<{ outputs: readonly Readonly<{ path: string }>[] }>,
|
||||
context: z.RefinementCtx,
|
||||
): void {
|
||||
const paths = artifact.outputs.map(({ path }) => path);
|
||||
if (new Set(paths).size !== paths.length) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["outputs"],
|
||||
message: "output paths must be unique",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const bundleOutputInventoryArtifactSchema = z
|
||||
.object(bundleOutputInventoryShape)
|
||||
.strict()
|
||||
.superRefine(addUniqueBundleOutputIssues);
|
||||
|
||||
const bundleMeasurementSchema = z
|
||||
.object({ path: nonEmptyString, gzipBytes: z.int().nonnegative() })
|
||||
.strict();
|
||||
const bundleClassificationSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
generatedAt: timestamp,
|
||||
context: z
|
||||
.object({
|
||||
nodeVersion: nonEmptyString,
|
||||
packageManager: nonEmptyString,
|
||||
runnerImage: nonEmptyString,
|
||||
})
|
||||
.strict(),
|
||||
outputs: z.array(outputDigestSchema).min(1),
|
||||
initialFiles: z.array(nonEmptyString),
|
||||
lazyFiles: z.array(nonEmptyString),
|
||||
missingImports: z.array(nonEmptyString),
|
||||
})
|
||||
.strict();
|
||||
const bundleThresholdsSchema = z
|
||||
.object({
|
||||
initialJsGzipBytes: z.int().positive(),
|
||||
lazyChunkGzipBytes: z.int().positive(),
|
||||
})
|
||||
.strict();
|
||||
const bundleBudgetResultSchema = z
|
||||
.object({
|
||||
initialPassed: z.boolean(),
|
||||
lazyResults: z.array(
|
||||
bundleMeasurementSchema.extend({
|
||||
threshold: z.int().positive(),
|
||||
passed: z.boolean(),
|
||||
}),
|
||||
),
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const bundlePerformanceArtifactSchema = z
|
||||
.object({
|
||||
...bundleOutputInventoryShape,
|
||||
measurements: z
|
||||
.object({
|
||||
initialJsGzipBytes: z.int().nonnegative(),
|
||||
lazyChunks: z.array(bundleMeasurementSchema),
|
||||
})
|
||||
.strict(),
|
||||
classification: bundleClassificationSchema,
|
||||
missingOutputs: z.array(nonEmptyString),
|
||||
thresholds: bundleThresholdsSchema,
|
||||
results: bundleBudgetResultSchema,
|
||||
fixtures: z.tuple([
|
||||
z.object({ name: z.literal("initial-js-over-budget"), passed: z.boolean() }).strict(),
|
||||
z.object({ name: z.literal("lazy-chunk-over-budget"), passed: z.boolean() }).strict(),
|
||||
]),
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((artifact, context) => {
|
||||
addUniqueBundleOutputIssues(artifact, context);
|
||||
const issue = (path: PropertyKey[], message: string) =>
|
||||
context.addIssue({ code: "custom", path, message });
|
||||
const uniqueSorted = (values: readonly string[]) =>
|
||||
new Set(values).size === values.length &&
|
||||
JSON.stringify(values) === JSON.stringify([...values].sort());
|
||||
for (const [field, values] of [
|
||||
["initialFiles", artifact.classification.initialFiles],
|
||||
["lazyFiles", artifact.classification.lazyFiles],
|
||||
["missingImports", artifact.classification.missingImports],
|
||||
["missingOutputs", artifact.missingOutputs],
|
||||
] as const) {
|
||||
if (!uniqueSorted(values)) {
|
||||
issue(
|
||||
field === "missingOutputs" ? [field] : ["classification", field],
|
||||
"paths must be unique and sorted",
|
||||
);
|
||||
}
|
||||
}
|
||||
const initial = new Set(artifact.classification.initialFiles);
|
||||
if (artifact.classification.lazyFiles.some((file) => initial.has(file))) {
|
||||
issue(["classification"], "initial and lazy files must be disjoint");
|
||||
}
|
||||
const outputs = new Map(
|
||||
artifact.outputs.map((output) => [output.path.replace(/^dist\//u, ""), output]),
|
||||
);
|
||||
const expectedMissing = [
|
||||
...artifact.classification.initialFiles,
|
||||
...artifact.classification.lazyFiles,
|
||||
].filter((file) => !outputs.has(file)).sort();
|
||||
if (JSON.stringify(artifact.missingOutputs) !== JSON.stringify(expectedMissing)) {
|
||||
issue(["missingOutputs"], "must equal classified JavaScript outputs not found in inventory");
|
||||
}
|
||||
const expectedInitialBytes = artifact.classification.initialFiles.reduce(
|
||||
(total, file) => total + (outputs.get(file)?.gzipBytes ?? 0),
|
||||
0,
|
||||
);
|
||||
if (artifact.measurements.initialJsGzipBytes !== expectedInitialBytes) {
|
||||
issue(["measurements", "initialJsGzipBytes"], "must equal classified initial output bytes");
|
||||
}
|
||||
const expectedLazyChunks = artifact.classification.lazyFiles.map((file) => ({
|
||||
path: file,
|
||||
gzipBytes: outputs.get(file)?.gzipBytes ?? 0,
|
||||
}));
|
||||
if (JSON.stringify(artifact.measurements.lazyChunks) !== JSON.stringify(expectedLazyChunks)) {
|
||||
issue(["measurements", "lazyChunks"], "must equal classified lazy output bytes");
|
||||
}
|
||||
const expectedInitialPassed =
|
||||
artifact.measurements.initialJsGzipBytes <= artifact.thresholds.initialJsGzipBytes;
|
||||
if (artifact.results.initialPassed !== expectedInitialPassed) {
|
||||
issue(["results", "initialPassed"], "must agree with initial threshold");
|
||||
}
|
||||
const expectedLazyResults = artifact.measurements.lazyChunks.map((chunk) => ({
|
||||
...chunk,
|
||||
threshold: artifact.thresholds.lazyChunkGzipBytes,
|
||||
passed: chunk.gzipBytes <= artifact.thresholds.lazyChunkGzipBytes,
|
||||
}));
|
||||
if (JSON.stringify(artifact.results.lazyResults) !== JSON.stringify(expectedLazyResults)) {
|
||||
issue(["results", "lazyResults"], "must agree with lazy measurements and threshold");
|
||||
}
|
||||
const expectedBudgetPassed =
|
||||
expectedInitialPassed && expectedLazyResults.every(({ passed }) => passed);
|
||||
if (artifact.results.passed !== expectedBudgetPassed) {
|
||||
issue(["results", "passed"], "must agree with budget results");
|
||||
}
|
||||
const expectedPassed =
|
||||
expectedBudgetPassed &&
|
||||
artifact.fixtures.every(({ passed }) => passed) &&
|
||||
artifact.classification.missingImports.length === 0 &&
|
||||
artifact.missingOutputs.length === 0;
|
||||
if (artifact.passed !== expectedPassed) {
|
||||
issue(["passed"], "must agree with budgets, fixtures, and manifest integrity");
|
||||
}
|
||||
});
|
||||
|
||||
const cyclonedxComponentSchema = z
|
||||
.object({
|
||||
@@ -571,3 +722,694 @@ export const runbookRecordArtifactSchema = z
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const failureList = z.array(nonEmptyString).max(4_096);
|
||||
const sourceOrFixtureMode = z.enum(["source", "negative-fixture"]);
|
||||
const namedBooleanResultSchema = z
|
||||
.object({ id: nonEmptyString, passed: z.boolean() })
|
||||
.strict();
|
||||
|
||||
function addPassedFailureInvariant(
|
||||
artifact: Readonly<{ passed: boolean; failures: readonly string[] }>,
|
||||
context: z.RefinementCtx,
|
||||
): void {
|
||||
if (artifact.passed !== (artifact.failures.length === 0)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["passed"],
|
||||
message: "passed must agree with failures",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addUniqueStringIssues(
|
||||
values: readonly string[],
|
||||
path: PropertyKey[],
|
||||
context: z.RefinementCtx,
|
||||
): void {
|
||||
if (new Set(values).size !== values.length) {
|
||||
context.addIssue({ code: "custom", path, message: "must not contain duplicates" });
|
||||
}
|
||||
}
|
||||
|
||||
export const automatedA11yArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
generatedAt: timestamp,
|
||||
scope: z.array(nonEmptyString).min(1).max(128),
|
||||
threshold: z.object({ critical: z.literal(0), serious: z.literal(0) }).strict(),
|
||||
automatedStatus: z.literal("passed"),
|
||||
manualReview: z.literal("see artifacts/tests/a11y-manual/report.json"),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((artifact, context) => {
|
||||
addUniqueStringIssues(artifact.scope, ["scope"], context);
|
||||
if (JSON.stringify(artifact.scope) !== JSON.stringify(MANUAL_A11Y_ROUTE_IDS)) {
|
||||
context.addIssue({ code: "custom", path: ["scope"], message: "must match the installed route registry" });
|
||||
}
|
||||
});
|
||||
|
||||
const manualA11yResultSchema = z
|
||||
.object({
|
||||
routeId: nonEmptyString,
|
||||
path: nonEmptyString,
|
||||
reviewer: z.string().nullable(),
|
||||
reviewedAt: z.string().nullable(),
|
||||
releaseId: z.string().nullable(),
|
||||
failures: failureList,
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((result, context) => {
|
||||
if (result.path !== `artifacts/tests/a11y-manual/${result.routeId}.md`) {
|
||||
context.addIssue({ code: "custom", path: ["path"], message: "path must match routeId" });
|
||||
}
|
||||
const hasIdentity = Boolean(
|
||||
result.reviewer &&
|
||||
result.releaseId &&
|
||||
result.reviewedAt &&
|
||||
Number.isFinite(Date.parse(result.reviewedAt)),
|
||||
);
|
||||
if (result.passed !== (result.failures.length === 0 && hasIdentity)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["passed"],
|
||||
message: "passed must agree with failures and review identity",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const manualA11yReportArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
generatedAt: timestamp,
|
||||
scope: z.array(nonEmptyString).min(1).max(128),
|
||||
results: z.array(manualA11yResultSchema).min(1).max(128),
|
||||
coherentRelease: z.boolean(),
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((artifact, context) => {
|
||||
const routeIds = artifact.results.map(({ routeId }) => routeId);
|
||||
addUniqueStringIssues(artifact.scope, ["scope"], context);
|
||||
addUniqueStringIssues(routeIds, ["results"], context);
|
||||
if (JSON.stringify(artifact.scope) !== JSON.stringify(MANUAL_A11Y_ROUTE_IDS)) {
|
||||
context.addIssue({ code: "custom", path: ["scope"], message: "must match the installed route registry" });
|
||||
}
|
||||
if (JSON.stringify(routeIds) !== JSON.stringify(artifact.scope)) {
|
||||
context.addIssue({ code: "custom", path: ["results"], message: "result routeIds must match scope" });
|
||||
}
|
||||
const releaseIds = artifact.results.map(({ releaseId }) => releaseId);
|
||||
const coherentRelease =
|
||||
releaseIds.every((releaseId): releaseId is string => Boolean(releaseId)) &&
|
||||
new Set(releaseIds).size === 1;
|
||||
if (artifact.coherentRelease !== coherentRelease) {
|
||||
context.addIssue({ code: "custom", path: ["coherentRelease"], message: "must represent one non-empty releaseId" });
|
||||
}
|
||||
if (
|
||||
artifact.passed !==
|
||||
(coherentRelease && artifact.results.every(({ passed }) => passed))
|
||||
) {
|
||||
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with route results" });
|
||||
}
|
||||
});
|
||||
|
||||
const architectureDependencySchema = z
|
||||
.object({
|
||||
source: nonEmptyString,
|
||||
target: nonEmptyString,
|
||||
specifier: nonEmptyString,
|
||||
kind: z.enum(["local", "external"]),
|
||||
})
|
||||
.strict();
|
||||
const architectureUnresolvedSchema = z
|
||||
.object({ source: nonEmptyString, specifier: nonEmptyString, reason: nonEmptyString })
|
||||
.strict();
|
||||
const architectureViolationSchema = z
|
||||
.object({
|
||||
rule: nonEmptyString,
|
||||
severity: nonEmptyString,
|
||||
source: nonEmptyString,
|
||||
target: nonEmptyString,
|
||||
cycle: z.array(nonEmptyString).optional(),
|
||||
})
|
||||
.strict();
|
||||
const staticImportGraphSchema = z
|
||||
.object({
|
||||
analyzer: z.literal("babel-parser-node-resolver"),
|
||||
modules: z.array(nonEmptyString),
|
||||
dependencies: z.array(architectureDependencySchema),
|
||||
unresolved: z.array(architectureUnresolvedSchema),
|
||||
parseFailures: z.array(
|
||||
z.object({ source: nonEmptyString, reason: nonEmptyString }).strict(),
|
||||
),
|
||||
cycles: z.array(z.array(nonEmptyString).min(1)),
|
||||
violations: z.array(architectureViolationSchema),
|
||||
summary: z
|
||||
.object({
|
||||
modules: z.int().nonnegative(),
|
||||
typescriptModules: z.int().nonnegative(),
|
||||
dependencies: z.int().nonnegative(),
|
||||
localDependencies: z.int().nonnegative(),
|
||||
unresolved: z.int().nonnegative(),
|
||||
parseFailures: z.int().nonnegative(),
|
||||
cycles: z.int().nonnegative(),
|
||||
errors: z.int().nonnegative(),
|
||||
typeScriptOnlyPolicyPassed: z.boolean(),
|
||||
nonTypeScriptExecutableSources: z.int().nonnegative(),
|
||||
})
|
||||
.strict(),
|
||||
fixtureChecks: z
|
||||
.object({ passed: z.boolean(), checks: z.array(nonEmptyString), failures: failureList })
|
||||
.strict(),
|
||||
typeScriptOnlySourcePolicy: z
|
||||
.object({
|
||||
checkedRoots: z.array(nonEmptyString).min(1),
|
||||
exceptionsAllowed: z.literal(false),
|
||||
violations: z.array(nonEmptyString),
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((graph, context) => {
|
||||
const counts = [
|
||||
["modules", graph.modules.length],
|
||||
["dependencies", graph.dependencies.length],
|
||||
["localDependencies", graph.dependencies.filter(({ kind }) => kind === "local").length],
|
||||
["unresolved", graph.unresolved.length],
|
||||
["parseFailures", graph.parseFailures.length],
|
||||
["cycles", graph.cycles.length],
|
||||
["errors", graph.violations.filter(({ severity }) => severity === "error").length],
|
||||
["nonTypeScriptExecutableSources", graph.typeScriptOnlySourcePolicy.violations.length],
|
||||
] as const;
|
||||
for (const [field, expected] of counts) {
|
||||
if (graph.summary[field] !== expected) {
|
||||
context.addIssue({ code: "custom", path: ["summary", field], message: "count does not match evidence rows" });
|
||||
}
|
||||
}
|
||||
if (graph.summary.typescriptModules > graph.summary.modules) {
|
||||
context.addIssue({ code: "custom", path: ["summary", "typescriptModules"], message: "cannot exceed modules" });
|
||||
}
|
||||
if (graph.fixtureChecks.passed !== (graph.fixtureChecks.failures.length === 0)) {
|
||||
context.addIssue({ code: "custom", path: ["fixtureChecks", "passed"], message: "must agree with failures" });
|
||||
}
|
||||
if (
|
||||
graph.typeScriptOnlySourcePolicy.passed !==
|
||||
(graph.typeScriptOnlySourcePolicy.violations.length === 0) ||
|
||||
graph.summary.typeScriptOnlyPolicyPassed !== graph.typeScriptOnlySourcePolicy.passed
|
||||
) {
|
||||
context.addIssue({ code: "custom", path: ["typeScriptOnlySourcePolicy", "passed"], message: "must agree with violations and summary" });
|
||||
}
|
||||
});
|
||||
const dependencyCruiserSummarySchema = z
|
||||
.object({
|
||||
violations: z.array(jsonObject),
|
||||
error: z.int().nonnegative(),
|
||||
warn: z.int().nonnegative(),
|
||||
info: z.int().nonnegative(),
|
||||
ignore: z.int().nonnegative(),
|
||||
totalCruised: z.int().nonnegative(),
|
||||
totalDependenciesCruised: z.int().nonnegative(),
|
||||
})
|
||||
.catchall(z.json());
|
||||
export const architectureDependencyReportArtifactSchema = z.union([
|
||||
z
|
||||
.object({
|
||||
modules: z.array(jsonObject),
|
||||
summary: dependencyCruiserSummarySchema,
|
||||
staticImportGraph: staticImportGraphSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
summary: z.object({ errors: z.literal(1) }).strict(),
|
||||
dependencyCruiserOutput: z.string(),
|
||||
staticImportGraph: staticImportGraphSchema,
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
|
||||
export const designSystemReportArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
mode: sourceOrFixtureMode,
|
||||
checkedTokenCount: z.int().positive(),
|
||||
failures: failureList,
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine(addPassedFailureInvariant);
|
||||
|
||||
export const i18nReportArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
mode: sourceOrFixtureMode,
|
||||
localeCount: z.int().positive(),
|
||||
messageKeyCount: z.int().positive(),
|
||||
checkedFiles: z.int().nonnegative(),
|
||||
failures: failureList,
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine(addPassedFailureInvariant);
|
||||
|
||||
export const diagnosticsReportArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
mode: sourceOrFixtureMode,
|
||||
telemetryEventCount: z.int().positive(),
|
||||
diagnosticEventCount: z.int().positive(),
|
||||
checkedFiles: z.int().nonnegative(),
|
||||
failures: failureList,
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine(addPassedFailureInvariant);
|
||||
|
||||
export const realtimeBoundariesArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
sourceRoot: nonEmptyString,
|
||||
violations: z.array(
|
||||
z
|
||||
.object({
|
||||
ruleId: z.enum([
|
||||
"NATIVE_REALTIME_API_OUTSIDE_ADAPTER",
|
||||
"PRESENTATION_INTERVAL_OWNER",
|
||||
"UNSELECTED_REALTIME_RUNTIME_COMPOSED",
|
||||
]),
|
||||
file: nonEmptyString,
|
||||
line: z.int().positive(),
|
||||
})
|
||||
.strict(),
|
||||
),
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((artifact, context) => {
|
||||
if (artifact.passed !== (artifact.violations.length === 0)) {
|
||||
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with violations" });
|
||||
}
|
||||
const keys = artifact.violations.map(({ ruleId, file, line }) => `${file}\0${line}\0${ruleId}`);
|
||||
addUniqueStringIssues(keys, ["violations"], context);
|
||||
});
|
||||
|
||||
const optionalRecipeBundleOutputSchema = z
|
||||
.object({
|
||||
fileName: nonEmptyString,
|
||||
bytes: z.int().nonnegative(),
|
||||
gzipBytes: z.int().nonnegative(),
|
||||
sha256,
|
||||
})
|
||||
.strict();
|
||||
const optionalRecipeBundleMeasurementSchema = z
|
||||
.object({
|
||||
recipeId: nonEmptyString,
|
||||
sourceRoots: z.array(nonEmptyString).min(1),
|
||||
sourceFileCount: z.int().positive(),
|
||||
toolchain: z
|
||||
.object({
|
||||
bundler: z.literal("vite"),
|
||||
viteVersion: nonEmptyString,
|
||||
mode: z.literal("production"),
|
||||
target: z.literal("es2022"),
|
||||
format: z.literal("es"),
|
||||
minifier: z.literal("esbuild"),
|
||||
treeshake: z.literal(false),
|
||||
compression: z.literal("node-zlib-gzip"),
|
||||
})
|
||||
.strict(),
|
||||
outputs: z.array(optionalRecipeBundleOutputSchema).min(1),
|
||||
bytes: z.int().nonnegative(),
|
||||
gzipBytes: z.int().nonnegative(),
|
||||
bundleBudgetGzipBytes: z.int().positive(),
|
||||
remainingGzipBytes: z.int(),
|
||||
sha256,
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((measurement, context) => {
|
||||
if (measurement.bytes !== measurement.outputs.reduce((total, output) => total + output.bytes, 0)) {
|
||||
context.addIssue({ code: "custom", path: ["bytes"], message: "must equal output bytes" });
|
||||
}
|
||||
if (measurement.gzipBytes !== measurement.outputs.reduce((total, output) => total + output.gzipBytes, 0)) {
|
||||
context.addIssue({ code: "custom", path: ["gzipBytes"], message: "must equal output gzip bytes" });
|
||||
}
|
||||
if (measurement.remainingGzipBytes !== measurement.bundleBudgetGzipBytes - measurement.gzipBytes) {
|
||||
context.addIssue({ code: "custom", path: ["remainingGzipBytes"], message: "must equal budget minus gzip bytes" });
|
||||
}
|
||||
if (measurement.passed !== (measurement.gzipBytes <= measurement.bundleBudgetGzipBytes)) {
|
||||
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with bundle budget" });
|
||||
}
|
||||
});
|
||||
const optionalRecipeReferenceRuntimeSchema = z
|
||||
.object({
|
||||
status: z.literal("AVAILABLE_NOT_COMPOSED"),
|
||||
coveredCapabilities: z.array(nonEmptyString).min(1),
|
||||
sourceRoots: z.array(nonEmptyString).min(1),
|
||||
conformanceScripts: z.array(nonEmptyString).min(1),
|
||||
productionComposition: z.literal(false),
|
||||
})
|
||||
.strict();
|
||||
const optionalRecipeViolationSchema = z
|
||||
.object({ ruleId: nonEmptyString, path: nonEmptyString, detail: nonEmptyString.optional() })
|
||||
.strict();
|
||||
export const optionalRecipesArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
decisionId: z.literal("VD-10"),
|
||||
selectedCapabilities: z.array(nonEmptyString).max(0),
|
||||
referenceRuntimes: z.array(
|
||||
z.object({ id: nonEmptyString, referenceRuntime: optionalRecipeReferenceRuntimeSchema }).strict(),
|
||||
).min(1),
|
||||
recipeCount: z.int().nonnegative(),
|
||||
productionRuntimeDependencies: z.array(nonEmptyString).nullable(),
|
||||
referenceRuntimeBundleBudgets: z.array(optionalRecipeBundleMeasurementSchema).min(1),
|
||||
bundleStatus: z.enum(["PASS", "FAIL", "NOT_BUILT"]),
|
||||
violations: z.array(optionalRecipeViolationSchema),
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((artifact, context) => {
|
||||
if (artifact.recipeCount < artifact.referenceRuntimes.length) {
|
||||
context.addIssue({ code: "custom", path: ["recipeCount"], message: "cannot be smaller than reference runtimes" });
|
||||
}
|
||||
if (artifact.passed !== (artifact.violations.length === 0)) {
|
||||
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with violations" });
|
||||
}
|
||||
const runtimeIds = artifact.referenceRuntimes.map(({ id }) => id).sort();
|
||||
const budgetIds = artifact.referenceRuntimeBundleBudgets.map(({ recipeId }) => recipeId).sort();
|
||||
addUniqueStringIssues(runtimeIds, ["referenceRuntimes"], context);
|
||||
addUniqueStringIssues(budgetIds, ["referenceRuntimeBundleBudgets"], context);
|
||||
if (JSON.stringify(runtimeIds) !== JSON.stringify(budgetIds)) {
|
||||
context.addIssue({ code: "custom", path: ["referenceRuntimeBundleBudgets"], message: "must cover every reference runtime" });
|
||||
}
|
||||
});
|
||||
|
||||
const OPTIONAL_RECIPE_FIXTURE_IDS = [
|
||||
"cleanup-omission",
|
||||
"unselected-runtime-dependency",
|
||||
"server-state-policy",
|
||||
"vendor-direct-import",
|
||||
"credential-leak",
|
||||
"server-state-source-duplication",
|
||||
"production-imports-recipe",
|
||||
"reference-runtime-not-composed",
|
||||
"reference-runtime-not-bundled",
|
||||
"reference-runtime-module-not-bundled",
|
||||
"reference-runtime-bundle-over-budget",
|
||||
] as const;
|
||||
const OPTIONAL_RECIPE_BUDGET_FIXTURE_IDS = [
|
||||
"file-transfer",
|
||||
"offline-indexeddb",
|
||||
"realtime",
|
||||
"service-worker-pwa",
|
||||
] as const;
|
||||
export const optionalRecipeFixturesArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
results: z.array(
|
||||
namedBooleanResultSchema.extend({ id: z.enum(OPTIONAL_RECIPE_FIXTURE_IDS) }),
|
||||
).length(OPTIONAL_RECIPE_FIXTURE_IDS.length),
|
||||
bundleBudgetFixtures: z.array(
|
||||
z
|
||||
.object({
|
||||
recipeId: z.enum(OPTIONAL_RECIPE_BUDGET_FIXTURE_IDS),
|
||||
gzipBytes: z.int().nonnegative(),
|
||||
fixtureBudgetGzipBytes: z.int().positive(),
|
||||
rejected: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
).length(OPTIONAL_RECIPE_BUDGET_FIXTURE_IDS.length),
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((artifact, context) => {
|
||||
addUniqueStringIssues(artifact.results.map(({ id }) => id), ["results"], context);
|
||||
addUniqueStringIssues(artifact.bundleBudgetFixtures.map(({ recipeId }) => recipeId), ["bundleBudgetFixtures"], context);
|
||||
if (artifact.passed !== artifact.results.every(({ passed }) => passed)) {
|
||||
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with fixture results" });
|
||||
}
|
||||
artifact.bundleBudgetFixtures.forEach((fixture, index) => {
|
||||
if (fixture.rejected !== (fixture.gzipBytes > fixture.fixtureBudgetGzipBytes)) {
|
||||
context.addIssue({ code: "custom", path: ["bundleBudgetFixtures", index, "rejected"], message: "must agree with fixture budget" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const registryCompatibilityValueSchema = z.union([
|
||||
z.enum(["none", "additive", "behavior-change", "breaking"]),
|
||||
z.boolean(),
|
||||
]);
|
||||
const REGISTRY_COMPATIBILITY_FIXTURE_IDS = [
|
||||
"ordering-only",
|
||||
"row-addition",
|
||||
"behavior-change",
|
||||
"row-removal",
|
||||
"field-type-narrowing",
|
||||
"route-path-change",
|
||||
"registry-contract-narrowing",
|
||||
"breaking-evidence-required",
|
||||
"tampered-baseline-digest",
|
||||
] as const;
|
||||
export const registryCompatibilityFixturesArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
results: z.array(
|
||||
z
|
||||
.object({
|
||||
id: z.enum(REGISTRY_COMPATIBILITY_FIXTURE_IDS),
|
||||
expected: registryCompatibilityValueSchema,
|
||||
actual: registryCompatibilityValueSchema,
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
).length(REGISTRY_COMPATIBILITY_FIXTURE_IDS.length),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((artifact, context) => {
|
||||
addUniqueStringIssues(artifact.results.map(({ id }) => id), ["results"], context);
|
||||
artifact.results.forEach((result, index) => {
|
||||
if (result.passed !== (result.actual === result.expected)) {
|
||||
context.addIssue({ code: "custom", path: ["results", index, "passed"], message: "must agree with expected and actual" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const buildDigestSchema = z.union([sha256, z.literal("BUILD_FAILED")]);
|
||||
export const reproducibleBuildArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
sourceDateEpoch: z.string().regex(/^\d+$/u),
|
||||
buildId: nonEmptyString,
|
||||
commitSha: nonEmptyString,
|
||||
releaseId: nonEmptyString,
|
||||
runnerImage: nonEmptyString,
|
||||
firstDigest: buildDigestSchema,
|
||||
secondDigest: buildDigestSchema,
|
||||
restored: z.boolean(),
|
||||
status: z.enum(["PASS", "FAIL"]),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((artifact, context) => {
|
||||
const passed =
|
||||
artifact.restored &&
|
||||
artifact.firstDigest !== "BUILD_FAILED" &&
|
||||
artifact.firstDigest === artifact.secondDigest;
|
||||
if ((artifact.status === "PASS") !== passed) {
|
||||
context.addIssue({ code: "custom", path: ["status"], message: "must agree with build digests and restoration" });
|
||||
}
|
||||
});
|
||||
|
||||
const SUPPLY_CHAIN_FIXTURE_IDS = [
|
||||
"transitive-removal-is-real-diff",
|
||||
"tampered-integrity-rejected",
|
||||
"high-risk-self-approval-rejected",
|
||||
"denied-license-rejected",
|
||||
"critical-vulnerability-expired-exception-rejected",
|
||||
"sbom-provenance-mismatch-rejected",
|
||||
"dependency-ordering-deterministic",
|
||||
"baseline-digest-tamper-rejected",
|
||||
"vulnerability-provider-evidence-invalid",
|
||||
] as const;
|
||||
export const supplyChainFixturesArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
results: z.array(
|
||||
namedBooleanResultSchema.extend({ id: z.enum(SUPPLY_CHAIN_FIXTURE_IDS) }),
|
||||
).length(SUPPLY_CHAIN_FIXTURE_IDS.length),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((artifact, context) =>
|
||||
addUniqueStringIssues(artifact.results.map(({ id }) => id), ["results"], context)
|
||||
);
|
||||
|
||||
const providerFixtureResultSchema = z
|
||||
.object({ status: z.enum(["PASS", "FAIL_UNVERIFIED"]), failures: failureList })
|
||||
.strict()
|
||||
.superRefine((result, context) => {
|
||||
if ((result.status === "PASS") !== (result.failures.length === 0)) {
|
||||
context.addIssue({ code: "custom", path: ["status"], message: "must agree with failures" });
|
||||
}
|
||||
});
|
||||
export const supplyChainProviderFixturesArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
actualDefaultVerifier: providerFixtureResultSchema,
|
||||
fixtures: z
|
||||
.object({
|
||||
absent: providerFixtureResultSchema,
|
||||
validImmutable: providerFixtureResultSchema,
|
||||
wrongDigest: providerFixtureResultSchema,
|
||||
invalidTar: providerFixtureResultSchema,
|
||||
})
|
||||
.strict(),
|
||||
externalTreeCanary: providerFixtureResultSchema,
|
||||
passingFixtureCount: z.int().nonnegative(),
|
||||
status: z.enum(["PASS", "FAIL"]),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((artifact, context) => {
|
||||
const expectedPass =
|
||||
artifact.actualDefaultVerifier.status === "PASS" &&
|
||||
artifact.fixtures.validImmutable.status === "PASS" &&
|
||||
artifact.externalTreeCanary.status === "PASS" &&
|
||||
[artifact.fixtures.absent, artifact.fixtures.wrongDigest, artifact.fixtures.invalidTar]
|
||||
.every(({ status }) => status === "FAIL_UNVERIFIED");
|
||||
if (artifact.passingFixtureCount !== (artifact.fixtures.validImmutable.status === "PASS" ? 1 : 0)) {
|
||||
context.addIssue({ code: "custom", path: ["passingFixtureCount"], message: "must count the passing immutable fixture" });
|
||||
}
|
||||
if ((artifact.status === "PASS") !== expectedPass) {
|
||||
context.addIssue({ code: "custom", path: ["status"], message: "must agree with required fixture outcomes" });
|
||||
}
|
||||
});
|
||||
|
||||
const compatibilityClassificationSchema = z.enum(["additive", "breaking"]);
|
||||
export const compatibilityFixturesArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
generatedAt: timestamp,
|
||||
rules: z.array(nonEmptyString).length(5),
|
||||
results: z.array(
|
||||
z
|
||||
.object({
|
||||
family: z.enum(["api", "config", "storage", "release"]),
|
||||
expected: compatibilityClassificationSchema,
|
||||
actual: compatibilityClassificationSchema,
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
).length(8),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((artifact, context) => {
|
||||
const keys = artifact.results.map(({ family, expected }) => `${family}\0${expected}`);
|
||||
addUniqueStringIssues(keys, ["results"], context);
|
||||
artifact.results.forEach((result, index) => {
|
||||
if (result.passed !== (result.actual === result.expected)) {
|
||||
context.addIssue({ code: "custom", path: ["results", index, "passed"], message: "must agree with expected and actual" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export const documentationReviewArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
generatedAt: timestamp,
|
||||
status: z.literal("PASS_SCOPED"),
|
||||
reviewer: z.literal("wiki-diagram-reviewer"),
|
||||
standard: z.literal("rules/diagram-standards.md v2"),
|
||||
evidenceReport: z
|
||||
.object({ repoPath: nonEmptyString, canonicalPath: nonEmptyString, canonicalSha256: sha256 })
|
||||
.strict(),
|
||||
reportDigestValid: z.boolean(),
|
||||
results: z.array(
|
||||
z
|
||||
.object({
|
||||
diagram: z.enum(["overview", "staticDelivery"]),
|
||||
sourcePath: nonEmptyString,
|
||||
sha256,
|
||||
sourceReferenced: z.boolean(),
|
||||
digestReferenced: z.boolean(),
|
||||
reviewer: z.literal("wiki-diagram-reviewer"),
|
||||
score: z.number().min(0).max(100),
|
||||
scorePass: z.boolean(),
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
).length(2),
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((artifact, context) => {
|
||||
addUniqueStringIssues(artifact.results.map(({ diagram }) => diagram), ["results"], context);
|
||||
artifact.results.forEach((result, index) => {
|
||||
const passed = result.sourceReferenced && result.digestReferenced && result.scorePass;
|
||||
if (result.passed !== passed) {
|
||||
context.addIssue({ code: "custom", path: ["results", index, "passed"], message: "must agree with review evidence" });
|
||||
}
|
||||
});
|
||||
if (artifact.passed !== (artifact.reportDigestValid && artifact.results.every(({ passed }) => passed))) {
|
||||
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with report digest and review results" });
|
||||
}
|
||||
});
|
||||
|
||||
export const hostingHeadersArtifactSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
generatedAt: timestamp,
|
||||
mode: z.enum(["live", "invalid-live", "fixture"]),
|
||||
baseUrl: z.string().nullable(),
|
||||
providerVerificationRequired: z.boolean(),
|
||||
results: z.array(
|
||||
z
|
||||
.object({
|
||||
surface: nonEmptyString,
|
||||
header: nonEmptyString,
|
||||
expected: z.json(),
|
||||
observed: z.json().optional(),
|
||||
reason: nonEmptyString.optional(),
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
).min(1),
|
||||
passed: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((artifact, context) => {
|
||||
const keys = artifact.results.map(({ surface, header }) => `${surface}\0${header}`);
|
||||
addUniqueStringIssues(keys, ["results"], context);
|
||||
const requiredKeys = [
|
||||
...["index", "runtimeConfig", "releaseManifest"].flatMap((surface) =>
|
||||
[
|
||||
"cache-control",
|
||||
"content-type",
|
||||
"content-security-policy",
|
||||
"strict-transport-security",
|
||||
"x-frame-options",
|
||||
"referrer-policy",
|
||||
"x-content-type-options",
|
||||
"permissions-policy",
|
||||
].map((header) => `${surface}\0${header}`)
|
||||
),
|
||||
"hashedAsset\0cache-control",
|
||||
"hashedAsset\0content-type",
|
||||
"sourceMap\0public",
|
||||
"serviceWorker\0enabled",
|
||||
];
|
||||
for (const requiredKey of requiredKeys) {
|
||||
if (!keys.includes(requiredKey)) {
|
||||
context.addIssue({ code: "custom", path: ["results"], message: `missing required probe: ${requiredKey}` });
|
||||
}
|
||||
}
|
||||
if (artifact.providerVerificationRequired !== (artifact.mode !== "live")) {
|
||||
context.addIssue({ code: "custom", path: ["providerVerificationRequired"], message: "must agree with hosting mode" });
|
||||
}
|
||||
if ((artifact.mode === "live") !== (artifact.baseUrl !== null)) {
|
||||
context.addIssue({ code: "custom", path: ["baseUrl"], message: "must be present only for live mode" });
|
||||
}
|
||||
if (artifact.passed !== artifact.results.every(({ passed }) => passed)) {
|
||||
context.addIssue({ code: "custom", path: ["passed"], message: "must agree with probe results" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ export type CiWorkflowFileSystem = Readonly<{
|
||||
readFile(target: string): Promise<Buffer>;
|
||||
open(target: string, flags: number, mode: number): Promise<{
|
||||
writeFile(content: string, encoding: "utf8"): Promise<unknown>;
|
||||
chmod(mode: number): Promise<unknown>;
|
||||
sync(): Promise<unknown>;
|
||||
close(): Promise<unknown>;
|
||||
}>;
|
||||
@@ -226,11 +227,6 @@ function renderStep(
|
||||
` name: ${yamlScalar(requiredTransferName(transfers, step.transferId))}`,
|
||||
` path: ${yamlScalar(step.path)}`,
|
||||
];
|
||||
case "validate-candidate-archive":
|
||||
return [
|
||||
" - name: Validate immutable candidate before extraction",
|
||||
` run: node scripts/verify-ci-candidate-archive.ts --archive ${shellDoubleQuoted(step.archivePath)}`,
|
||||
];
|
||||
case "extract":
|
||||
return [
|
||||
" - name: Verify and extract the candidate through one inode-bound operation",
|
||||
@@ -263,8 +259,10 @@ function renderStep(
|
||||
` PROMOTION_CLEANUP_TOKEN: \${{ steps.${step.finalizerStepId}.outputs.cleanup_token }}`,
|
||||
` PROMOTION_RUNNER_TEMP_DEV: \${{ steps.${step.finalizerStepId}.outputs.runner_temp_dev }}`,
|
||||
` PROMOTION_RUNNER_TEMP_INO: \${{ steps.${step.finalizerStepId}.outputs.runner_temp_ino }}`,
|
||||
` PROMOTION_STAGING_DEV: \${{ steps.${step.finalizerStepId}.outputs.staging_dev }}`,
|
||||
` PROMOTION_STAGING_INO: \${{ steps.${step.finalizerStepId}.outputs.staging_ino }}`,
|
||||
" run: |",
|
||||
' if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ]; then',
|
||||
' if [ -n "$PROMOTION_STAGING_ROOT" ] && [ -n "$PROMOTION_CLEANUP_TOKEN" ] && [ -n "$PROMOTION_RUNNER_TEMP_DEV" ] && [ -n "$PROMOTION_RUNNER_TEMP_INO" ] && [ -n "$PROMOTION_STAGING_DEV" ] && [ -n "$PROMOTION_STAGING_INO" ]; then',
|
||||
" node scripts/cleanup-verified-promotion.ts",
|
||||
" fi",
|
||||
];
|
||||
@@ -296,6 +294,7 @@ function requiredStepActionUses(stepKind: string): string {
|
||||
function renderCondition(condition: CiWorkflowJob["condition"]): string | null {
|
||||
const expressions: Record<CiWorkflowJob["condition"], string | null> = {
|
||||
always: null,
|
||||
"needs-success": null,
|
||||
merge: "${{ gitea.event_name != 'workflow_dispatch' || inputs.stage != 'documentation' }}",
|
||||
release: "${{ startsWith(gitea.ref, 'refs/tags/v') || (gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'release' || inputs.stage == 'production' || inputs.stage == 'field')) }}",
|
||||
production: "${{ gitea.event_name == 'workflow_dispatch' && (inputs.stage == 'production' || inputs.stage == 'field') }}",
|
||||
@@ -412,6 +411,7 @@ export function createCiWorkflowGenerator(
|
||||
let failure: unknown;
|
||||
try {
|
||||
await handle.writeFile(expected.toString("utf8"), "utf8");
|
||||
await handle.chmod(0o644);
|
||||
await handle.sync();
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "node:fs/promises";
|
||||
|
||||
import {
|
||||
bundlePerformanceArtifactSchema,
|
||||
bundleOutputInventoryArtifactSchema,
|
||||
buildManifestArtifactSchema,
|
||||
dependencyDiffArtifactSchema,
|
||||
dependencyInventoryArtifactSchema,
|
||||
@@ -327,7 +327,7 @@ await mkdir("artifacts/release", { recursive: true });
|
||||
await mkdir("artifacts/security", { recursive: true });
|
||||
await writeValidatedJsonArtifact({
|
||||
path: "artifacts/performance/bundle.json",
|
||||
schema: bundlePerformanceArtifactSchema,
|
||||
schema: bundleOutputInventoryArtifactSchema,
|
||||
value: bundleReport,
|
||||
});
|
||||
await writeValidatedJsonArtifact({
|
||||
|
||||
@@ -10,21 +10,37 @@ import type {
|
||||
} from "../contracts/ci-gates.ts";
|
||||
import { ciContractReportSchema } from "./ci-contract-report.ts";
|
||||
import {
|
||||
architectureDependencyReportArtifactSchema,
|
||||
automatedA11yArtifactSchema,
|
||||
buildManifestArtifactSchema,
|
||||
bundlePerformanceArtifactSchema,
|
||||
compatibilityFixturesArtifactSchema,
|
||||
dependencyDiffArtifactSchema,
|
||||
dependencyInventoryArtifactSchema,
|
||||
designSystemReportArtifactSchema,
|
||||
diagnosticsReportArtifactSchema,
|
||||
documentationReviewArtifactSchema,
|
||||
fieldWebVitalsArtifactSchema,
|
||||
hostingHeadersArtifactSchema,
|
||||
i18nReportArtifactSchema,
|
||||
jsonSchemaDocumentArtifactSchema,
|
||||
labPerformanceArtifactSchema,
|
||||
licenseReportArtifactSchema,
|
||||
manualA11yReportArtifactSchema,
|
||||
moduleInventoryArtifactSchema,
|
||||
optionalRecipeFixturesArtifactSchema,
|
||||
optionalRecipesArtifactSchema,
|
||||
provenanceArtifactSchema,
|
||||
realtimeBoundariesArtifactSchema,
|
||||
registryGovernanceRunArtifactSchema,
|
||||
registryCompatibilityFixturesArtifactSchema,
|
||||
registrySnapshotArtifactSchema,
|
||||
releaseVerificationArtifactSchema,
|
||||
reproducibleBuildArtifactSchema,
|
||||
runbookRecordArtifactSchema,
|
||||
sbomArtifactSchema,
|
||||
supplyChainFixturesArtifactSchema,
|
||||
supplyChainProviderFixturesArtifactSchema,
|
||||
supplyChainVerificationArtifactSchema,
|
||||
vulnerabilityReportArtifactSchema,
|
||||
} from "../contracts/release-artifacts.ts";
|
||||
@@ -40,10 +56,6 @@ import { normalizeRepositoryRelativePath } from "./repository-file-inventory.ts"
|
||||
import { secretScanSarifSchema } from "./secret-scan-evaluator.ts";
|
||||
import { testEvidenceReportSchema } from "./test-evidence-artifact.ts";
|
||||
|
||||
const jsonObjectSchema = z.record(z.string(), z.json()).refine(
|
||||
(value) => Object.keys(value).length > 0,
|
||||
"generic JSON artifact must be a non-empty object",
|
||||
);
|
||||
const coverageCounterSchema = z
|
||||
.object({
|
||||
total: z.number().int().nonnegative(),
|
||||
@@ -184,7 +196,22 @@ type ExecutableJsonSchemaId = Extract<
|
||||
>["executableSchemaId"];
|
||||
|
||||
const executableJsonSchemas: Readonly<Record<ExecutableJsonSchemaId, ZodType>> = Object.freeze({
|
||||
"generic-json-object": jsonObjectSchema,
|
||||
"automated-a11y": automatedA11yArtifactSchema,
|
||||
"manual-a11y": manualA11yReportArtifactSchema,
|
||||
"architecture-dependency-report": architectureDependencyReportArtifactSchema,
|
||||
"design-system-contract": designSystemReportArtifactSchema,
|
||||
"i18n-contract": i18nReportArtifactSchema,
|
||||
"diagnostics-contract": diagnosticsReportArtifactSchema,
|
||||
"realtime-boundaries": realtimeBoundariesArtifactSchema,
|
||||
"optional-recipes": optionalRecipesArtifactSchema,
|
||||
"optional-recipe-fixtures": optionalRecipeFixturesArtifactSchema,
|
||||
"registry-compatibility-fixtures": registryCompatibilityFixturesArtifactSchema,
|
||||
"reproducible-build": reproducibleBuildArtifactSchema,
|
||||
"supply-chain-fixtures": supplyChainFixturesArtifactSchema,
|
||||
"supply-chain-provider-fixtures": supplyChainProviderFixturesArtifactSchema,
|
||||
"compatibility-fixtures": compatibilityFixturesArtifactSchema,
|
||||
"documentation-review": documentationReviewArtifactSchema,
|
||||
"hosting-headers": hostingHeadersArtifactSchema,
|
||||
"coverage-summary-v8": coverageSummarySchema,
|
||||
"risk-coverage-v3": riskCoverageArtifactSchema,
|
||||
"build-manifest": buildManifestArtifactSchema,
|
||||
@@ -213,6 +240,12 @@ const executableJsonSchemas: Readonly<Record<ExecutableJsonSchemaId, ZodType>> =
|
||||
"ci-contract-report": ciContractReportSchema,
|
||||
});
|
||||
|
||||
export function hasCiArtifactSemanticValidator(
|
||||
schema: CiGateArtifactSchema,
|
||||
): boolean {
|
||||
return schema.kind !== "json" || schema.executableSchemaId in executableJsonSchemas;
|
||||
}
|
||||
|
||||
type ReadHandle = Readonly<{
|
||||
stat(): Promise<Stats>;
|
||||
read(
|
||||
@@ -253,9 +286,7 @@ export async function validateCiArtifact(
|
||||
if (!/^#|\[[^\]]+\]|\S/u.test(text)) throw new TypeError(`invalid Markdown artifact: ${relative}`);
|
||||
return;
|
||||
case "html":
|
||||
if (!/^\s*(?:<!doctype\s+html\s*>\s*)?<html\b[^>]*>[\s\S]*<\/html\s*>\s*$/iu.test(text)) {
|
||||
throw new TypeError(`invalid HTML artifact: ${relative}`);
|
||||
}
|
||||
assertWellFormedHtml(text, relative);
|
||||
return;
|
||||
case "junit":
|
||||
assertWellFormedJUnitXml(text, relative);
|
||||
@@ -355,51 +386,85 @@ async function readHandleBounded(
|
||||
return captured.subarray(0, offset);
|
||||
}
|
||||
|
||||
const MAX_DOCUMENT_DEPTH = 256;
|
||||
const MAX_DOCUMENT_UNITS = 100_000;
|
||||
|
||||
function assertWellFormedJUnitXml(source: string, relative: string): void {
|
||||
const invalid = () => new TypeError(`invalid JUnit artifact: ${relative}`);
|
||||
if (/<!DOCTYPE\b|<!ENTITY\b/iu.test(source)) throw invalid();
|
||||
if (!hasOnlyXmlCharacters(source)) throw invalid();
|
||||
const stack: string[] = [];
|
||||
let root: string | undefined;
|
||||
let rootClosed = false;
|
||||
let declarationSeen = false;
|
||||
let units = 0;
|
||||
let cursor = 0;
|
||||
while (cursor < source.length) {
|
||||
const open = source.indexOf("<", cursor);
|
||||
const text = source.slice(cursor, open < 0 ? source.length : open);
|
||||
if (stack.length === 0 && text.trim()) throw invalid();
|
||||
if ((text.includes("]]>") || !hasValidXmlEntities(text)) && text.length > 0) throw invalid();
|
||||
if (text.length > 0 && ++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
if (open < 0) break;
|
||||
if (source.startsWith("<!--", open)) {
|
||||
const close = source.indexOf("-->", open + 4);
|
||||
if (close < 0 || source.slice(open + 4, close).includes("--")) throw invalid();
|
||||
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
cursor = close + 3;
|
||||
continue;
|
||||
}
|
||||
if (source.startsWith("<![CDATA[", open)) {
|
||||
const close = source.indexOf("]]>", open + 9);
|
||||
if (stack.length === 0 || close < 0) throw invalid();
|
||||
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
cursor = close + 3;
|
||||
continue;
|
||||
}
|
||||
if (source.startsWith("<?", open)) {
|
||||
const close = source.indexOf("?>", open + 2);
|
||||
if (root || close < 0) throw invalid();
|
||||
const processingInstruction = source.slice(open, close + 2);
|
||||
const match = /^<\?([A-Za-z_][\w:.-]*)(?:\s+[\s\S]*?)?\?>$/u.exec(
|
||||
processingInstruction,
|
||||
);
|
||||
if (!match) throw invalid();
|
||||
if (match[1]!.toLowerCase() === "xml") {
|
||||
if (
|
||||
declarationSeen ||
|
||||
source.slice(0, open).trim() ||
|
||||
!/^<\?xml\s+version\s*=\s*(["'])1\.0\1(?:\s+encoding\s*=\s*(["'])UTF-8\2)?\s*\?>$/u.test(
|
||||
processingInstruction,
|
||||
)
|
||||
) {
|
||||
throw invalid();
|
||||
}
|
||||
declarationSeen = true;
|
||||
}
|
||||
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
cursor = close + 2;
|
||||
continue;
|
||||
}
|
||||
const close = source.indexOf(">", open + 1);
|
||||
if (source.startsWith("<!", open)) throw invalid();
|
||||
const close = markupEnd(source, open + 1);
|
||||
if (close < 0) throw invalid();
|
||||
const tag = source.slice(open, close + 1);
|
||||
const closing = /^<\/([A-Za-z_][\w:.-]*)\s*>$/u.exec(tag);
|
||||
if (closing) {
|
||||
if (stack.pop() !== closing[1]) throw invalid();
|
||||
if (stack.length === 0) rootClosed = true;
|
||||
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
cursor = close + 1;
|
||||
continue;
|
||||
}
|
||||
const opening = /^<([A-Za-z_][\w:.-]*)([\s\S]*?)(\/?)>$/u.exec(tag);
|
||||
if (!opening || rootClosed || !hasValidXmlAttributes(opening[2] ?? "")) throw invalid();
|
||||
root ??= opening[1];
|
||||
if (opening[3] !== "/") stack.push(opening[1]!);
|
||||
if (opening[3] !== "/") {
|
||||
if (stack.length >= MAX_DOCUMENT_DEPTH) throw invalid();
|
||||
stack.push(opening[1]!);
|
||||
}
|
||||
else if (stack.length === 0) rootClosed = true;
|
||||
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
cursor = close + 1;
|
||||
}
|
||||
if (stack.length > 0 || !rootClosed || (root !== "testsuite" && root !== "testsuites")) {
|
||||
@@ -412,14 +477,236 @@ function hasValidXmlAttributes(source: string): boolean {
|
||||
const names = new Set<string>();
|
||||
while (remaining.length > 0) {
|
||||
if (!remaining.trim()) return true;
|
||||
const match = /^\s+([A-Za-z_:][\w:.-]*)\s*=\s*(?:"[^"<]*"|'[^'<]*')/u.exec(remaining);
|
||||
const match = /^\s+([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"<]*)"|'([^'<]*)')/u.exec(remaining);
|
||||
if (!match || names.has(match[1]!)) return false;
|
||||
if (!hasValidXmlEntities(match[2] ?? match[3] ?? "")) return false;
|
||||
names.add(match[1]!);
|
||||
remaining = remaining.slice(match[0].length);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasOnlyXmlCharacters(source: string): boolean {
|
||||
for (const character of source) {
|
||||
const codePoint = character.codePointAt(0)!;
|
||||
if (
|
||||
codePoint !== 0x09 &&
|
||||
codePoint !== 0x0a &&
|
||||
codePoint !== 0x0d &&
|
||||
(codePoint < 0x20 ||
|
||||
(codePoint > 0xd7ff && codePoint < 0xe000) ||
|
||||
(codePoint > 0xfffd && codePoint < 0x10000) ||
|
||||
codePoint > 0x10ffff)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasValidXmlEntities(source: string): boolean {
|
||||
let cursor = 0;
|
||||
while (cursor < source.length) {
|
||||
const ampersand = source.indexOf("&", cursor);
|
||||
if (ampersand < 0) return true;
|
||||
const semicolon = source.indexOf(";", ampersand + 1);
|
||||
if (semicolon < 0) return false;
|
||||
const entity = source.slice(ampersand + 1, semicolon);
|
||||
if (!["amp", "lt", "gt", "apos", "quot"].includes(entity)) {
|
||||
const decimal = /^#([0-9]+)$/u.exec(entity);
|
||||
const hexadecimal = /^#x([a-fA-F0-9]+)$/u.exec(entity);
|
||||
if (!decimal && !hexadecimal) return false;
|
||||
const codePoint = Number.parseInt((decimal ?? hexadecimal)![1]!, decimal ? 10 : 16);
|
||||
if (
|
||||
!Number.isSafeInteger(codePoint) ||
|
||||
(codePoint !== 0x09 &&
|
||||
codePoint !== 0x0a &&
|
||||
codePoint !== 0x0d &&
|
||||
(codePoint < 0x20 ||
|
||||
(codePoint > 0xd7ff && codePoint < 0xe000) ||
|
||||
(codePoint > 0xfffd && codePoint < 0x10000) ||
|
||||
codePoint > 0x10ffff))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
cursor = semicolon + 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const HTML_VOID_ELEMENTS = new Set([
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]);
|
||||
const HTML_RAW_TEXT_ELEMENTS = new Set(["script", "style", "textarea", "title"]);
|
||||
|
||||
function assertWellFormedHtml(source: string, relative: string): void {
|
||||
const invalid = () => new TypeError(`invalid HTML artifact: ${relative}`);
|
||||
if (/<!ENTITY\b|<!DOCTYPE\s+html\s+[^>]*\[/iu.test(source)) throw invalid();
|
||||
const stack: string[] = [];
|
||||
let cursor = 0;
|
||||
let units = 0;
|
||||
let doctypeSeen = false;
|
||||
let rootSeen = false;
|
||||
let rootClosed = false;
|
||||
let playwrightPayloadSeen = false;
|
||||
while (cursor < source.length) {
|
||||
const rawElement = stack.at(-1);
|
||||
if (rawElement && HTML_RAW_TEXT_ELEMENTS.has(rawElement)) {
|
||||
const closingStart = source.toLowerCase().indexOf(`</${rawElement}`, cursor);
|
||||
if (closingStart < 0) throw invalid();
|
||||
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
cursor = closingStart;
|
||||
}
|
||||
const open = source.indexOf("<", cursor);
|
||||
const text = source.slice(cursor, open < 0 ? source.length : open);
|
||||
if (stack.length === 0 && text.trim()) throw invalid();
|
||||
if (text.length > 0 && ++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
if (open < 0) break;
|
||||
if (source.startsWith("<!--", open)) {
|
||||
const close = source.indexOf("-->", open + 4);
|
||||
if (close < 0 || source.slice(open + 4, close).includes("--")) throw invalid();
|
||||
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
cursor = close + 3;
|
||||
continue;
|
||||
}
|
||||
const declarationEnd = source.indexOf(">", open + 2);
|
||||
if (source.slice(open, open + 9).toLowerCase() === "<!doctype") {
|
||||
if (
|
||||
declarationEnd < 0 ||
|
||||
doctypeSeen ||
|
||||
rootSeen ||
|
||||
source.slice(open, declarationEnd + 1).toLowerCase() !== "<!doctype html>"
|
||||
) {
|
||||
throw invalid();
|
||||
}
|
||||
doctypeSeen = true;
|
||||
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
cursor = declarationEnd + 1;
|
||||
continue;
|
||||
}
|
||||
if (source.startsWith("<!", open) || source.startsWith("<?", open)) throw invalid();
|
||||
const close = markupEnd(source, open + 1);
|
||||
if (close < 0) throw invalid();
|
||||
const tag = source.slice(open, close + 1);
|
||||
const closing = /^<\/([A-Za-z][A-Za-z0-9:-]*)\s*>$/u.exec(tag);
|
||||
if (closing) {
|
||||
const name = closing[1]!.toLowerCase();
|
||||
if (stack.pop() !== name) throw invalid();
|
||||
if (stack.length === 0) {
|
||||
if (name === "html") rootClosed = true;
|
||||
else if (name === "template" && playwrightPayloadSeen) {
|
||||
// Playwright emits its base64 report template after </html>; HTML5
|
||||
// reparents this token into the document body. It is the sole
|
||||
// permitted generated-report sidecar and does not create a new root.
|
||||
} else {
|
||||
throw invalid();
|
||||
}
|
||||
}
|
||||
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
cursor = close + 1;
|
||||
continue;
|
||||
}
|
||||
const opening = /^<([A-Za-z][A-Za-z0-9:-]*)([\s\S]*?)(\/?)>$/u.exec(tag);
|
||||
if (!opening) throw invalid();
|
||||
const name = opening[1]!.toLowerCase();
|
||||
const attributes = parseHtmlAttributes(opening[2] ?? "");
|
||||
if (!attributes || name.includes(":")) throw invalid();
|
||||
if (!rootSeen) {
|
||||
if (name !== "html" || opening[3] === "/") throw invalid();
|
||||
rootSeen = true;
|
||||
} else if (name === "html") {
|
||||
throw invalid();
|
||||
}
|
||||
if (rootClosed && stack.length === 0) {
|
||||
if (
|
||||
playwrightPayloadSeen ||
|
||||
name !== "template" ||
|
||||
attributes.get("id") !== "playwrightReportBase64" ||
|
||||
opening[3] === "/"
|
||||
) {
|
||||
throw invalid();
|
||||
}
|
||||
playwrightPayloadSeen = true;
|
||||
}
|
||||
if (!HTML_VOID_ELEMENTS.has(name) && opening[3] !== "/") {
|
||||
if (stack.length >= MAX_DOCUMENT_DEPTH) throw invalid();
|
||||
stack.push(name);
|
||||
} else if (stack.length === 0 && name === "html") {
|
||||
rootClosed = true;
|
||||
}
|
||||
if (++units > MAX_DOCUMENT_UNITS) throw invalid();
|
||||
cursor = close + 1;
|
||||
}
|
||||
if (stack.length > 0 || !rootSeen || !rootClosed) throw invalid();
|
||||
}
|
||||
|
||||
function markupEnd(source: string, start: number): number {
|
||||
let quote: "\"" | "'" | undefined;
|
||||
for (let index = start; index < source.length; index += 1) {
|
||||
const character = source[index];
|
||||
if (quote) {
|
||||
if (character === quote) quote = undefined;
|
||||
} else if (character === "\"" || character === "'") {
|
||||
quote = character;
|
||||
} else if (character === ">") {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function parseHtmlAttributes(source: string): ReadonlyMap<string, string> | null {
|
||||
const attributes = new Map<string, string>();
|
||||
let cursor = 0;
|
||||
while (cursor < source.length) {
|
||||
const whitespace = /^\s+/u.exec(source.slice(cursor));
|
||||
if (!whitespace) return source.slice(cursor).trim() ? null : attributes;
|
||||
cursor += whitespace[0].length;
|
||||
if (cursor >= source.length) return attributes;
|
||||
const nameMatch = /^[A-Za-z_:][A-Za-z0-9:._-]*/u.exec(source.slice(cursor));
|
||||
if (!nameMatch) return null;
|
||||
const name = nameMatch[0].toLowerCase();
|
||||
if (attributes.has(name) || name.includes(":")) return null;
|
||||
cursor += nameMatch[0].length;
|
||||
const spacing = /^\s*/u.exec(source.slice(cursor))![0];
|
||||
cursor += spacing.length;
|
||||
let value = "";
|
||||
if (source[cursor] === "=") {
|
||||
cursor += 1;
|
||||
cursor += /^\s*/u.exec(source.slice(cursor))![0].length;
|
||||
const quote = source[cursor];
|
||||
if (quote === "\"" || quote === "'") {
|
||||
const end = source.indexOf(quote, cursor + 1);
|
||||
if (end < 0) return null;
|
||||
value = source.slice(cursor + 1, end);
|
||||
if (value.includes("<")) return null;
|
||||
cursor = end + 1;
|
||||
} else {
|
||||
const unquoted = /^[^\s"'`=<>]+/u.exec(source.slice(cursor));
|
||||
if (!unquoted) return null;
|
||||
value = unquoted[0];
|
||||
cursor += value.length;
|
||||
}
|
||||
}
|
||||
attributes.set(name, value);
|
||||
}
|
||||
return attributes;
|
||||
}
|
||||
|
||||
function assertSameIdentity(before: Stats, after: Stats, relative: string): void {
|
||||
if (
|
||||
!Number.isSafeInteger(before.dev) ||
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
PROMOTED_FILE_NAMES,
|
||||
type PromotedFileName,
|
||||
} from "../contracts/promotion-artifacts.ts";
|
||||
import {
|
||||
PROMOTION_VERIFIER_ID,
|
||||
PROMOTION_VERIFIER_VERSION,
|
||||
assertDistinctProviderTrust,
|
||||
evaluatePromotionEvidence,
|
||||
providerVerificationArtifactSchema,
|
||||
provenanceProviderAttestationSchema,
|
||||
vulnerabilityProviderReportSchema,
|
||||
trustPolicySha256,
|
||||
type ProviderTrust,
|
||||
} from "./provider-evidence.ts";
|
||||
import { verifyCapturedCiCandidateArchive } from "./ci-candidate-archive.ts";
|
||||
import { LOCAL_EVIDENCE_ASSESSMENT_PATH } from "./release-candidate.ts";
|
||||
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
||||
|
||||
export type ExactPromotionBundle = Readonly<
|
||||
Partial<Record<PromotedFileName, Buffer>>
|
||||
>;
|
||||
|
||||
export type ExactPromotionExpectedContext = Readonly<{
|
||||
run: Readonly<{ id: string; attempt: number }>;
|
||||
sourceRevision: string;
|
||||
archiveSha256: string;
|
||||
sourceSetSha256?: string;
|
||||
bundleSha256?: string;
|
||||
distSha256?: string;
|
||||
lockfileSha256?: string;
|
||||
}>;
|
||||
|
||||
export async function verifyExactPromotionBundle(
|
||||
files: ExactPromotionBundle,
|
||||
options: Readonly<{
|
||||
vulnerabilityTrust: ProviderTrust;
|
||||
provenanceTrust: ProviderTrust;
|
||||
expected: ExactPromotionExpectedContext;
|
||||
nowEpochMs?: () => number;
|
||||
}>,
|
||||
): Promise<Readonly<{ status: "PASS" }>> {
|
||||
assertDistinctProviderTrust(options);
|
||||
assertExternalExpectedContext(options.expected);
|
||||
const names = Object.keys(files).sort(asciiCompare);
|
||||
const expectedNames = [...PROMOTED_FILE_NAMES].sort(asciiCompare);
|
||||
if (JSON.stringify(names) !== JSON.stringify(expectedNames)) {
|
||||
throw new Error("promotion bundle must contain the exact five canonical files");
|
||||
}
|
||||
for (const name of PROMOTED_FILE_NAMES) {
|
||||
if (!Buffer.isBuffer(files[name])) {
|
||||
throw new TypeError(`promotion bundle file is missing or not captured bytes: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const archiveBytes = files["release-candidate.tar.gz"]!;
|
||||
const vulnerabilityBytes = files["vulnerability-report.json"]!;
|
||||
const provenanceBytes = files["provenance-attestation.json"]!;
|
||||
const providerBytes = files["provider-verification.json"]!;
|
||||
const promotionBytes = files["promotion-verification.json"]!;
|
||||
const vulnerability = vulnerabilityProviderReportSchema.parse(parseJson(
|
||||
vulnerabilityBytes,
|
||||
"vulnerability report",
|
||||
));
|
||||
const provenance = provenanceProviderAttestationSchema.parse(parseJson(
|
||||
provenanceBytes,
|
||||
"provenance attestation",
|
||||
));
|
||||
const provider = providerVerificationArtifactSchema.parse(parseJson(
|
||||
providerBytes,
|
||||
"provider verification",
|
||||
));
|
||||
const promotion = providerVerificationArtifactSchema.parse(parseJson(
|
||||
promotionBytes,
|
||||
"promotion verification",
|
||||
));
|
||||
|
||||
if (
|
||||
provider.artifactType !== "provider-verification" ||
|
||||
promotion.artifactType !== "promotion-verification"
|
||||
) {
|
||||
throw new Error("promotion verification artifact role mismatch");
|
||||
}
|
||||
for (const [label, record] of [
|
||||
["provider", provider],
|
||||
["promotion", promotion],
|
||||
] as const) {
|
||||
if (
|
||||
record.verifier.id !== PROMOTION_VERIFIER_ID ||
|
||||
record.verifier.version !== PROMOTION_VERIFIER_VERSION
|
||||
) {
|
||||
throw new Error(`${label} verification literal verifier identity mismatch`);
|
||||
}
|
||||
if (record.status !== "PASS" || record.failures.length !== 0) {
|
||||
throw new Error(`${label} verification must be PASS without failures`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
provider.vulnerabilityStatus !== "PASS" ||
|
||||
provider.provenanceAttestationStatus !== "PASS"
|
||||
) {
|
||||
throw new Error("provider verification subordinate statuses must both be PASS");
|
||||
}
|
||||
if (promotion.localEvidenceStatus !== "PASS") {
|
||||
throw new Error("promotion local evidence subordinate status must be PASS");
|
||||
}
|
||||
|
||||
assertEqual("shared verifiedAt", provider.verifiedAt, promotion.verifiedAt);
|
||||
assertEqual("shared run", provider.run, promotion.run);
|
||||
assertEqual("shared source", provider.source, promotion.source);
|
||||
assertEqual("shared candidate", provider.candidate, promotion.candidate);
|
||||
assertEqual(
|
||||
"shared provider evidence",
|
||||
provider.providerEvidence,
|
||||
promotion.providerEvidence,
|
||||
);
|
||||
assertEqual(
|
||||
"shared trust policy",
|
||||
provider.trustPolicySha256,
|
||||
promotion.trustPolicySha256,
|
||||
);
|
||||
assertEqual("external expected run", provider.run, options.expected.run);
|
||||
assertEqual(
|
||||
"external expected source revision",
|
||||
provider.source.revision,
|
||||
options.expected.sourceRevision,
|
||||
);
|
||||
assertEqual(
|
||||
"external expected archive digest",
|
||||
provider.candidate.archiveSha256,
|
||||
options.expected.archiveSha256,
|
||||
);
|
||||
for (const [label, actual, expected] of [
|
||||
["source set", provider.source.sourceSetSha256, options.expected.sourceSetSha256],
|
||||
["bundle", provider.candidate.bundleSha256, options.expected.bundleSha256],
|
||||
["dist", provider.candidate.distSha256, options.expected.distSha256],
|
||||
["lockfile", provider.candidate.lockfileSha256, options.expected.lockfileSha256],
|
||||
] as const) {
|
||||
if (expected !== undefined) {
|
||||
assertEqual(`external expected ${label} digest`, actual, expected);
|
||||
}
|
||||
}
|
||||
const anchoredTrustPolicySha256 = trustPolicySha256(options);
|
||||
if (provider.trustPolicySha256 !== anchoredTrustPolicySha256) {
|
||||
throw new Error("verification trust policy does not match anchored provider keys");
|
||||
}
|
||||
|
||||
if (promotion.providerVerificationSha256 !== sha256(providerBytes)) {
|
||||
throw new Error("promotion provider verification byte hash mismatch");
|
||||
}
|
||||
if (
|
||||
provider.candidate.archiveSha256 !== sha256(archiveBytes) ||
|
||||
provider.providerEvidence.vulnerabilityReportSha256 !== sha256(vulnerabilityBytes) ||
|
||||
provider.providerEvidence.provenanceAttestationSha256 !== sha256(provenanceBytes)
|
||||
) {
|
||||
if (provider.candidate.archiveSha256 !== sha256(archiveBytes)) {
|
||||
throw new Error("candidate archive actual digest mismatch");
|
||||
}
|
||||
if (
|
||||
provider.providerEvidence.vulnerabilityReportSha256 !==
|
||||
sha256(vulnerabilityBytes)
|
||||
) {
|
||||
throw new Error("vulnerability report actual digest mismatch");
|
||||
}
|
||||
throw new Error("provenance attestation actual digest mismatch");
|
||||
}
|
||||
|
||||
for (const [label, evidence, nonce, keyId, fingerprint] of [
|
||||
[
|
||||
"vulnerability",
|
||||
vulnerability,
|
||||
provider.providerEvidence.vulnerabilityInvocationNonce,
|
||||
provider.providerEvidence.vulnerabilityKeyId,
|
||||
provider.providerEvidence.vulnerabilityKeyFingerprint,
|
||||
],
|
||||
[
|
||||
"provenance",
|
||||
provenance,
|
||||
provider.providerEvidence.provenanceInvocationNonce,
|
||||
provider.providerEvidence.provenanceKeyId,
|
||||
provider.providerEvidence.provenanceKeyFingerprint,
|
||||
],
|
||||
] as const) {
|
||||
assertEqual(`${label} run`, { id: evidence.run.id, attempt: evidence.run.attempt }, provider.run);
|
||||
assertEqual(`${label} source`, evidence.source, provider.source);
|
||||
assertEqual(`${label} candidate`, evidence.candidate, provider.candidate);
|
||||
if (
|
||||
evidence.run.invocationNonce !== nonce ||
|
||||
evidence.signature.keyId !== keyId ||
|
||||
evidence.signature.publicKeyFingerprint !== fingerprint
|
||||
) {
|
||||
throw new Error(`${label} provider evidence nonce or trust role mismatch`);
|
||||
}
|
||||
}
|
||||
if (provenance.subject.digest.sha256 !== provider.candidate.distSha256) {
|
||||
throw new Error("provenance subject dist digest mismatch");
|
||||
}
|
||||
if (vulnerability.findings.length !== 0) {
|
||||
throw new Error("vulnerability report is not PASS");
|
||||
}
|
||||
assertEqual(
|
||||
"signed secret scan attestation",
|
||||
vulnerability.secretScanAttestation,
|
||||
provider.providerEvidence.secretScanAttestation,
|
||||
);
|
||||
|
||||
let assessmentSha256: string | null = null;
|
||||
const localIdentityHolder: {
|
||||
current: null | Readonly<{
|
||||
sourceRevision: string;
|
||||
sourceSetSha256: string;
|
||||
assessmentSha256: string;
|
||||
secretScan: Readonly<{
|
||||
policySha256: string;
|
||||
sarifSha256: string;
|
||||
scanInputSha256: string;
|
||||
}>;
|
||||
}>;
|
||||
} = { current: null };
|
||||
await verifyCapturedCiCandidateArchive(
|
||||
archiveBytes,
|
||||
provider.candidate.archiveSha256,
|
||||
{
|
||||
verifyExtracted: async (extractionRoot, manifest) => {
|
||||
assertEqual("archive candidate", {
|
||||
bundleSha256: manifest.bundleSha256,
|
||||
distSha256: manifest.distSha256,
|
||||
lockfileSha256: manifest.lockfileSha256,
|
||||
}, {
|
||||
bundleSha256: provider.candidate.bundleSha256,
|
||||
distSha256: provider.candidate.distSha256,
|
||||
lockfileSha256: provider.candidate.lockfileSha256,
|
||||
});
|
||||
assessmentSha256 = sha256(
|
||||
await readFile(path.join(extractionRoot, LOCAL_EVIDENCE_ASSESSMENT_PATH)),
|
||||
);
|
||||
const local = await verifyArchivedLocalEvidence({
|
||||
extractionRoot,
|
||||
expectedManifest: manifest,
|
||||
});
|
||||
if (local.status !== "PASS" || !local.identity) {
|
||||
throw new Error(
|
||||
`exact-five archived local verification is not PASS: ${local.failures.join(", ")}`,
|
||||
);
|
||||
}
|
||||
localIdentityHolder.current = local.identity;
|
||||
},
|
||||
},
|
||||
);
|
||||
if (assessmentSha256 !== promotion.localEvidenceAssessmentSha256) {
|
||||
throw new Error("promotion local evidence assessment actual digest mismatch");
|
||||
}
|
||||
if (
|
||||
!localIdentityHolder.current ||
|
||||
localIdentityHolder.current.sourceRevision !== provider.source.revision ||
|
||||
localIdentityHolder.current.sourceSetSha256 !== provider.source.sourceSetSha256 ||
|
||||
localIdentityHolder.current.assessmentSha256 !== promotion.localEvidenceAssessmentSha256
|
||||
) {
|
||||
throw new Error("exact-five archived local identity mismatch");
|
||||
}
|
||||
assertEqual("archived secret scan attestation", {
|
||||
status: "PASS",
|
||||
localEvidenceAssessmentSha256: localIdentityHolder.current.assessmentSha256,
|
||||
sourceSetSha256: localIdentityHolder.current.sourceSetSha256,
|
||||
policySha256: localIdentityHolder.current.secretScan.policySha256,
|
||||
sarifSha256: localIdentityHolder.current.secretScan.sarifSha256,
|
||||
scanInputSha256: localIdentityHolder.current.secretScan.scanInputSha256,
|
||||
}, vulnerability.secretScanAttestation);
|
||||
const reevaluated = evaluatePromotionEvidence({
|
||||
expected: {
|
||||
run: provider.run,
|
||||
source: provider.source,
|
||||
candidate: provider.candidate,
|
||||
vulnerabilityInvocationNonce:
|
||||
provider.providerEvidence.vulnerabilityInvocationNonce,
|
||||
provenanceInvocationNonce:
|
||||
provider.providerEvidence.provenanceInvocationNonce,
|
||||
secretScanAttestation: provider.providerEvidence.secretScanAttestation,
|
||||
},
|
||||
localStatus: "PASS",
|
||||
vulnerabilityReport: vulnerability,
|
||||
provenanceAttestation: provenance,
|
||||
vulnerabilityTrust: options.vulnerabilityTrust,
|
||||
provenanceTrust: options.provenanceTrust,
|
||||
nowEpochMs: options.nowEpochMs,
|
||||
});
|
||||
if (
|
||||
reevaluated.status !== "PASS" ||
|
||||
reevaluated.vulnerabilityStatus !== "PASS" ||
|
||||
reevaluated.provenanceAttestationStatus !== "PASS"
|
||||
) {
|
||||
throw new Error(
|
||||
`exact-five provider signature/freshness revalidation is not PASS: ${reevaluated.failures.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return Object.freeze({ status: "PASS" as const });
|
||||
}
|
||||
|
||||
function assertExternalExpectedContext(
|
||||
expected: ExactPromotionExpectedContext,
|
||||
): void {
|
||||
if (
|
||||
!expected ||
|
||||
typeof expected.run?.id !== "string" ||
|
||||
expected.run.id.length === 0 ||
|
||||
!Number.isSafeInteger(expected.run.attempt) ||
|
||||
expected.run.attempt < 1 ||
|
||||
!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(expected.sourceRevision) ||
|
||||
!isSha256(expected.archiveSha256)
|
||||
) {
|
||||
throw new TypeError("external expected promotion context is invalid or incomplete");
|
||||
}
|
||||
for (const digest of [
|
||||
expected.sourceSetSha256,
|
||||
expected.bundleSha256,
|
||||
expected.distSha256,
|
||||
expected.lockfileSha256,
|
||||
]) {
|
||||
if (digest !== undefined && !isSha256(digest)) {
|
||||
throw new TypeError("external optional expected promotion digest is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isSha256(value: unknown): value is string {
|
||||
return typeof value === "string" && /^[a-f0-9]{64}$/u.test(value);
|
||||
}
|
||||
|
||||
function parseJson(bytes: Buffer, label: string): unknown {
|
||||
try {
|
||||
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
|
||||
} catch {
|
||||
throw new TypeError(`${label} is not strict UTF-8 JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEqual(label: string, left: unknown, right: unknown): void {
|
||||
if (JSON.stringify(left) !== JSON.stringify(right)) {
|
||||
throw new Error(`${label} mismatch`);
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(bytes: Buffer): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function asciiCompare(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
import { assertMatchesJsonSchema } from "./json-schema.ts";
|
||||
import {
|
||||
LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||
LOCAL_EVIDENCE_POLICY_INPUT_PATHS,
|
||||
LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS,
|
||||
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
collectDistOutputs,
|
||||
@@ -54,9 +56,12 @@ import {
|
||||
parseRepositoryFileInventoryPolicy,
|
||||
} from "./repository-file-inventory.ts";
|
||||
import {
|
||||
parseSecretScanPolicy,
|
||||
secretScanSarifSchema,
|
||||
evaluateRepositorySecretScan,
|
||||
verifyStoredSecretScan,
|
||||
} from "./secret-scan-evaluator.ts";
|
||||
import { secretScanRules } from "./secret-scan.ts";
|
||||
import {
|
||||
isValidSha512Integrity,
|
||||
parsePnpmLockfilePackages,
|
||||
@@ -301,37 +306,10 @@ export async function verifyLocalSupplyChainEvidence(
|
||||
export const LOCAL_EVIDENCE_VERIFIER_ID =
|
||||
"clean-architecture-frontend-template/local-evidence-verifier";
|
||||
export const LOCAL_EVIDENCE_VERIFIER_VERSION = "1";
|
||||
export const LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS = Object.freeze([
|
||||
"scripts/contracts/release-artifacts.ts",
|
||||
"scripts/create-release-candidate.ts",
|
||||
"scripts/generate-supply-chain.ts",
|
||||
"scripts/lib/build-manifest-outputs.ts",
|
||||
"scripts/lib/json-schema.ts",
|
||||
"scripts/lib/local-policy-evidence.ts",
|
||||
"scripts/lib/local-release-evidence.ts",
|
||||
"scripts/lib/release-candidate.ts",
|
||||
"scripts/lib/release-input-evidence.ts",
|
||||
"scripts/lib/release-runtime-coherence.ts",
|
||||
"scripts/lib/repository-file-inventory.ts",
|
||||
"scripts/lib/secret-scan-evaluator.ts",
|
||||
"scripts/lib/secret-scan-policy.ts",
|
||||
"scripts/lib/supply-chain.ts",
|
||||
"scripts/lib/validated-json-artifact.ts",
|
||||
"src/contracts/release-artifacts.ts",
|
||||
] as const);
|
||||
export const LOCAL_EVIDENCE_POLICY_INPUT_PATHS = Object.freeze([
|
||||
"config/security/dependency-baseline.approval.json",
|
||||
"config/security/dependency-baseline.json",
|
||||
"config/security/dependency-change-evidence.json",
|
||||
"config/security/dependency-policy.json",
|
||||
"config/security/secret-scan-policy.json",
|
||||
"config/security/vulnerability-exceptions.json",
|
||||
"config/security/vulnerability-policy.json",
|
||||
"schemas/artifacts/build-manifest.schema.json",
|
||||
"schemas/artifacts/dependency-inventory.schema.json",
|
||||
"schemas/artifacts/supply-chain-verification.schema.json",
|
||||
...LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS,
|
||||
] as const);
|
||||
export {
|
||||
LOCAL_EVIDENCE_POLICY_INPUT_PATHS,
|
||||
LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS,
|
||||
} from "./release-candidate.ts";
|
||||
|
||||
export async function createLocalEvidenceAssessment(
|
||||
repositoryRoot = process.cwd(),
|
||||
@@ -360,7 +338,7 @@ export async function createLocalEvidenceAssessment(
|
||||
files: evidenceInputs,
|
||||
};
|
||||
const evaluated = await evaluateProducerLocalChecks(root, candidate);
|
||||
const [build, release, provenance, supply, sbomDocument, policyInputs] = await Promise.all([
|
||||
const [build, release, provenance, supply, sbomDocument, policyInputs, secretScan] = await Promise.all([
|
||||
readJson(root, "artifacts/release/build-manifest.json").then((value) =>
|
||||
buildManifestArtifactSchema.parse(value),
|
||||
),
|
||||
@@ -381,6 +359,7 @@ export async function createLocalEvidenceAssessment(
|
||||
digestInput(root, policyPath),
|
||||
),
|
||||
),
|
||||
evaluateRepositorySecretScan({ repositoryRoot: root }),
|
||||
]);
|
||||
const identityFailures: string[] = [];
|
||||
if (build.commitSha !== release.commitSha) {
|
||||
@@ -421,6 +400,15 @@ export async function createLocalEvidenceAssessment(
|
||||
if (verifierSources.length !== LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS.length) {
|
||||
throw new Error("local assessment verifier source set is incomplete");
|
||||
}
|
||||
const secretPolicy = policyInputs.find(
|
||||
({ path: policyPath }) => policyPath === "config/security/secret-scan-policy.json",
|
||||
);
|
||||
const secretSarif = evidenceInputs.find(
|
||||
({ path: evidencePath }) => evidencePath === "artifacts/security/scan.sarif",
|
||||
);
|
||||
if (!secretPolicy || !secretSarif) {
|
||||
throw new Error("local assessment secret scan inputs are incomplete");
|
||||
}
|
||||
return localEvidenceAssessmentArtifactSchema.parse({
|
||||
schemaVersion: 1,
|
||||
artifactType: "local-evidence-assessment",
|
||||
@@ -440,6 +428,11 @@ export async function createLocalEvidenceAssessment(
|
||||
lockfileSha256: candidate.lockfileSha256,
|
||||
sbomSha256: sbom.sha256,
|
||||
},
|
||||
secretScan: {
|
||||
policySha256: secretPolicy.sha256,
|
||||
sarifSha256: secretSarif.sha256,
|
||||
scanInputSha256: secretScan.scanInputSha256,
|
||||
},
|
||||
policyInputs,
|
||||
evidenceInputs,
|
||||
checks,
|
||||
@@ -458,6 +451,7 @@ type LocalCheckName =
|
||||
async function evaluateProducerLocalChecks(
|
||||
root: string,
|
||||
candidate: ReleaseCandidateManifest,
|
||||
options: Readonly<{ archived?: boolean }> = {},
|
||||
): Promise<Readonly<{
|
||||
checks: Readonly<Record<LocalCheckName, "PASS" | "FAIL">>;
|
||||
failures: readonly string[];
|
||||
@@ -490,13 +484,16 @@ async function evaluateProducerLocalChecks(
|
||||
};
|
||||
|
||||
await evaluate("release", async () => {
|
||||
const [build, release, stored] = await Promise.all([
|
||||
const [build, release, runtime, stored] = await Promise.all([
|
||||
readJson(root, "artifacts/release/build-manifest.json").then((value) =>
|
||||
buildManifestArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "dist/release-manifest.json").then((value) =>
|
||||
releaseManifestArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "dist/config.json").then((value) =>
|
||||
runtimeConfigArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "artifacts/release/verification.json").then((value) =>
|
||||
releaseVerificationArtifactSchema.parse(value),
|
||||
),
|
||||
@@ -522,30 +519,104 @@ async function evaluateProducerLocalChecks(
|
||||
) {
|
||||
diagnostics.push("stored release verification is not a coherent PASS");
|
||||
}
|
||||
if (!runtime.BUILD_ID || !runtime.RELEASE_ID) {
|
||||
diagnostics.push("runtime release identity is missing");
|
||||
} else {
|
||||
const apiContractVersion =
|
||||
release.schemaVersion === 1 && "API_CONTRACT_VERSION" in runtime
|
||||
? runtime.API_CONTRACT_VERSION
|
||||
: undefined;
|
||||
const coherence = await verifyReleaseRuntimeCoherence({
|
||||
release,
|
||||
runtime: {
|
||||
BUILD_ID: runtime.BUILD_ID,
|
||||
RELEASE_ID: runtime.RELEASE_ID,
|
||||
CONFIG_SCHEMA_VERSION: runtime.CONFIG_SCHEMA_VERSION,
|
||||
...(apiContractVersion === undefined
|
||||
? {}
|
||||
: { API_CONTRACT_VERSION: apiContractVersion }),
|
||||
},
|
||||
contractPackages: EXPECTED_CONTRACT_SET_PACKAGES,
|
||||
});
|
||||
diagnostics.push(...coherence.mismatches.map((item) => `runtime:${item}`));
|
||||
}
|
||||
diagnostics.push(...(await verifyBuildManifestOutputs(build, { repositoryRoot: root })));
|
||||
return diagnostics;
|
||||
});
|
||||
await evaluate("supplyChain", async () => {
|
||||
const [supply, coherence] = await Promise.all([
|
||||
const [inventory, sbom, provenance, supply, coherence, lockfileBytes] = await Promise.all([
|
||||
readJson(root, "artifacts/release/dependency-inventory.json").then((value) =>
|
||||
dependencyInventoryArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "artifacts/release/sbom.cdx.json").then((value) =>
|
||||
sbomArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "artifacts/release/provenance.json").then((value) =>
|
||||
provenanceArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "artifacts/security/supply-chain-verification.json").then((value) =>
|
||||
supplyChainVerificationArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(root, "artifacts/security/supply-chain-coherence.json").then((value) =>
|
||||
supplyChainCoherenceReportSchema.parse(value),
|
||||
),
|
||||
readFile(path.join(root, "pnpm-lock.yaml")),
|
||||
]);
|
||||
const diagnostics: string[] = [];
|
||||
const lockfileSha256 = createHash("sha256").update(lockfileBytes).digest("hex");
|
||||
const outputs = await collectDistOutputs(root);
|
||||
const currentDistSha256 = distSha256(outputs);
|
||||
const sbomSha256 = supplyChainDigest(sbom);
|
||||
const independentlyCoherent = verifySupplyChainCoherence(
|
||||
sbom,
|
||||
inventory,
|
||||
provenance,
|
||||
currentDistSha256,
|
||||
);
|
||||
if (
|
||||
supply.localStatus !== "PASS" ||
|
||||
supply.failures.length > 0 ||
|
||||
supply.distSha256 !== candidate.distSha256 ||
|
||||
supply.lockfileSha256 !== candidate.lockfileSha256 ||
|
||||
supply.sbomSha256 !== sbomSha256 ||
|
||||
supply.sourceSetSha256 !== provenance.predicate.materials.sourceSetSha256 ||
|
||||
inventory.lockfileSha256 !== lockfileSha256 ||
|
||||
candidate.lockfileSha256 !== lockfileSha256 ||
|
||||
candidate.distSha256 !== currentDistSha256 ||
|
||||
coherence.status !== "PASS" ||
|
||||
coherence.failures.length > 0 ||
|
||||
coherence.distSha256 !== candidate.distSha256 ||
|
||||
coherence.lockfileSha256 !== candidate.lockfileSha256
|
||||
coherence.lockfileSha256 !== candidate.lockfileSha256 ||
|
||||
coherence.sbomSha256 !== sbomSha256 ||
|
||||
coherence.dependencyCount !== inventory.dependencies.length ||
|
||||
independentlyCoherent.failures.length > 0
|
||||
) {
|
||||
diagnostics.push("stored supply-chain evidence is not a coherent PASS");
|
||||
}
|
||||
diagnostics.push(...verifyLocalSupplyChainDefaults(supply));
|
||||
diagnostics.push(
|
||||
...verifyStoredDistChecksums(
|
||||
outputs,
|
||||
await readFile(path.join(root, "artifacts/release/checksums.txt"), "utf8"),
|
||||
),
|
||||
);
|
||||
const lockRows = parsePnpmLockfilePackages(lockfileBytes.toString("utf8"));
|
||||
const inventoryByIdentity = new Map(
|
||||
inventory.dependencies.map((entry) => [`${entry.name}@${entry.version}`, entry] as const),
|
||||
);
|
||||
if (lockRows.length !== inventory.dependencies.length) {
|
||||
diagnostics.push("transitive dependency count differs from lockfile");
|
||||
}
|
||||
for (const lockRow of lockRows) {
|
||||
const dependency = inventoryByIdentity.get(`${lockRow.name}@${lockRow.version}`);
|
||||
if (
|
||||
!dependency ||
|
||||
dependency.integrity !== lockRow.integrity ||
|
||||
!isValidSha512Integrity(lockRow.integrity)
|
||||
) {
|
||||
diagnostics.push(`lockfile inventory integrity mismatch: ${lockRow.name}@${lockRow.version}`);
|
||||
}
|
||||
}
|
||||
return diagnostics;
|
||||
});
|
||||
await evaluate("dependencyPolicy", async () => {
|
||||
@@ -596,6 +667,25 @@ async function evaluateProducerLocalChecks(
|
||||
);
|
||||
});
|
||||
await evaluate("secretScan", async () => {
|
||||
if (options.archived) {
|
||||
const policy = parseSecretScanPolicy(
|
||||
await readJson(root, "config/security/secret-scan-policy.json"),
|
||||
);
|
||||
const sarif = secretScanSarifSchema.parse(
|
||||
await readJson(root, "artifacts/security/scan.sarif"),
|
||||
);
|
||||
const diagnostics: string[] = [];
|
||||
if (
|
||||
policy.trackedRoots.length === 0 ||
|
||||
policy.generatedRoots.length === 0 ||
|
||||
sarif.runs[0]!.results.length > 0 ||
|
||||
JSON.stringify(sarif.runs[0]!.tool.driver.rules.map(({ id }) => id)) !==
|
||||
JSON.stringify(secretScanRules().map(({ id }) => id))
|
||||
) {
|
||||
diagnostics.push("archived secret scan is not an independently valid PASS");
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
const evaluation = await evaluateRepositorySecretScan({ repositoryRoot: root });
|
||||
return verifyStoredSecretScan(
|
||||
evaluation,
|
||||
@@ -640,6 +730,11 @@ export async function verifyArchivedLocalEvidence(input: Readonly<{
|
||||
sourceRevision: string;
|
||||
sourceSetSha256: string;
|
||||
assessmentSha256: string;
|
||||
secretScan: Readonly<{
|
||||
policySha256: string;
|
||||
sarifSha256: string;
|
||||
scanInputSha256: string;
|
||||
}>;
|
||||
}>;
|
||||
failures: readonly string[];
|
||||
}>> {
|
||||
@@ -696,6 +791,25 @@ export async function verifyArchivedLocalEvidence(input: Readonly<{
|
||||
if (JSON.stringify(policyPaths) !== JSON.stringify(LOCAL_EVIDENCE_POLICY_INPUT_PATHS)) {
|
||||
failures.push("local assessment policyInputs exact set mismatch");
|
||||
}
|
||||
for (const policyInput of assessment.policyInputs) {
|
||||
try {
|
||||
const bytes = await readFile(path.join(extractionRoot, policyInput.path));
|
||||
const member = extractedManifest.files.find(
|
||||
({ path: memberPath }) => memberPath === policyInput.path,
|
||||
);
|
||||
if (
|
||||
!member ||
|
||||
member.bytes !== policyInput.bytes ||
|
||||
member.sha256 !== policyInput.sha256 ||
|
||||
bytes.byteLength !== policyInput.bytes ||
|
||||
createHash("sha256").update(bytes).digest("hex") !== policyInput.sha256
|
||||
) {
|
||||
failures.push(`archived policy input binding mismatch: ${policyInput.path}`);
|
||||
}
|
||||
} catch {
|
||||
failures.push(`archived policy input is missing or invalid: ${policyInput.path}`);
|
||||
}
|
||||
}
|
||||
const verifierSources = assessment.policyInputs.filter(({ path: policyPath }) =>
|
||||
(LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS as readonly string[]).includes(policyPath),
|
||||
);
|
||||
@@ -715,6 +829,12 @@ export async function verifyArchivedLocalEvidence(input: Readonly<{
|
||||
const sbom = extractedManifest.files.find(
|
||||
({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json",
|
||||
);
|
||||
const secretPolicy = extractedManifest.files.find(
|
||||
({ path: memberPath }) => memberPath === "config/security/secret-scan-policy.json",
|
||||
);
|
||||
const secretSarif = extractedManifest.files.find(
|
||||
({ path: memberPath }) => memberPath === "artifacts/security/scan.sarif",
|
||||
);
|
||||
if (
|
||||
assessment.candidate.distSha256 !== extractedManifest.distSha256 ||
|
||||
assessment.candidate.lockfileSha256 !== extractedManifest.lockfileSha256 ||
|
||||
@@ -723,10 +843,54 @@ export async function verifyArchivedLocalEvidence(input: Readonly<{
|
||||
) {
|
||||
failures.push("local assessment candidate digest binding mismatch");
|
||||
}
|
||||
if (
|
||||
!secretPolicy ||
|
||||
!secretSarif ||
|
||||
assessment.secretScan.policySha256 !== secretPolicy.sha256 ||
|
||||
assessment.secretScan.sarifSha256 !== secretSarif.sha256
|
||||
) {
|
||||
failures.push("local assessment secret scan artifact binding mismatch");
|
||||
}
|
||||
if (assessment.status !== "PASS" || Object.values(assessment.checks).includes("FAIL")) {
|
||||
failures.push("local evidence assessment is not PASS");
|
||||
}
|
||||
|
||||
try {
|
||||
const [supply, coherence] = await Promise.all([
|
||||
readJson(extractionRoot, "artifacts/security/supply-chain-verification.json").then(
|
||||
(value) => supplyChainVerificationArtifactSchema.parse(value),
|
||||
),
|
||||
readJson(extractionRoot, "artifacts/security/supply-chain-coherence.json").then(
|
||||
(value) => supplyChainCoherenceReportSchema.parse(value),
|
||||
),
|
||||
]);
|
||||
if (
|
||||
supply.localStatus !== "PASS" ||
|
||||
supply.failures.length > 0 ||
|
||||
coherence.status !== "PASS" ||
|
||||
coherence.failures.length > 0
|
||||
) {
|
||||
failures.push("archived supply-chain subordinate evidence is not PASS");
|
||||
}
|
||||
} catch {
|
||||
failures.push("archived supply-chain subordinate evidence is missing or invalid");
|
||||
}
|
||||
|
||||
const independent = await evaluateProducerLocalChecks(
|
||||
extractionRoot,
|
||||
extractedManifest,
|
||||
{ archived: true },
|
||||
);
|
||||
if (
|
||||
independent.failures.length > 0 ||
|
||||
JSON.stringify(independent.checks) !== JSON.stringify(assessment.checks)
|
||||
) {
|
||||
failures.push(
|
||||
...independent.failures.map((failure) => `archived local check:${failure}`),
|
||||
);
|
||||
failures.push("archived local checks do not independently reproduce assessment PASS");
|
||||
}
|
||||
|
||||
const identities = await readArchivedIdentities(extractionRoot, failures);
|
||||
if (
|
||||
identities.buildRevision !== assessment.source.revision ||
|
||||
@@ -751,6 +915,7 @@ export async function verifyArchivedLocalEvidence(input: Readonly<{
|
||||
sourceRevision: passingAssessment.source.revision,
|
||||
sourceSetSha256: passingAssessment.source.sourceSetSha256,
|
||||
assessmentSha256,
|
||||
secretScan: passingAssessment.secretScan,
|
||||
})
|
||||
: null,
|
||||
failures: uniqueFailures,
|
||||
|
||||
@@ -1,5 +1,138 @@
|
||||
const packageScriptInvocation = /\b(?:(?:corepack\s+)?pnpm(?:\s+--?[A-Za-z][A-Za-z-]*(?:=[^\s;&|]+)?)*(?:\s+run)?|npm\s+run)\s+([A-Za-z0-9:_-]+)/gu;
|
||||
const pnpmNonScriptCommands = new Set(["dlx", "exec", "install"]);
|
||||
type PackageManager = "pnpm" | "npm" | "yarn";
|
||||
|
||||
type ManagerParseResult = Readonly<{
|
||||
dependencies: readonly string[];
|
||||
unsafeLifecycle: boolean;
|
||||
unsupportedManagerSyntax: boolean;
|
||||
}>;
|
||||
|
||||
type SuppressionState = {
|
||||
effective: boolean | undefined;
|
||||
contradictory: boolean;
|
||||
malformed: boolean;
|
||||
};
|
||||
|
||||
type ShellNpmScopeEnvironmentState = {
|
||||
autoExport: boolean;
|
||||
forbidden: boolean;
|
||||
uncertain: boolean;
|
||||
};
|
||||
|
||||
type ShellCommandPrefix = Readonly<{
|
||||
assignments: readonly Readonly<{
|
||||
dynamicName: boolean;
|
||||
name: string | null;
|
||||
}>[];
|
||||
commandIndex: number;
|
||||
uncertain: boolean;
|
||||
}>;
|
||||
|
||||
type EnvironmentCommandPrefix = Readonly<{
|
||||
assignmentNames: readonly string[];
|
||||
uncertain: boolean;
|
||||
}>;
|
||||
|
||||
type TokenizedShellSegment = Readonly<{
|
||||
tokens: readonly string[];
|
||||
expansionTokens: readonly boolean[];
|
||||
}>;
|
||||
|
||||
const managerNames = new Set<PackageManager>(["pnpm", "npm", "yarn"]);
|
||||
const managerOptionsWithValue = new Set([
|
||||
"-C", "--cache", "--cache-folder", "--config-dir", "--cwd", "--dir", "--filter",
|
||||
"--global-dir", "--globalconfig", "--home", "--lockfile-dir", "--mutex", "--prefix",
|
||||
"--registry", "--store-dir", "--userconfig", "--workspace", "--workspace-dir",
|
||||
]);
|
||||
const managerBooleanOptions = new Set([
|
||||
"--color", "--global", "--no-color", "--offline", "--prefer-offline", "--silent",
|
||||
"--use-stderr", "--verbose", "-g", "-s",
|
||||
]);
|
||||
const npmScriptDispatchBooleanOptions = new Set([
|
||||
"--foreground-scripts", "--if-present", "--ignore-scripts",
|
||||
]);
|
||||
const npmScriptDispatchScopeOptions = new Set([
|
||||
"--prefix", "--workspace", "--workspaces",
|
||||
]);
|
||||
const npmDispatchScopeEnvironmentNames = new Set([
|
||||
"npm_config_globalconfig", "npm_config_prefix", "npm_config_userconfig",
|
||||
"npm_config_workspace", "npm_config_workspaces",
|
||||
]);
|
||||
const npmIndirectConfigAuthorityOptions = new Set([
|
||||
"--globalconfig", "--userconfig",
|
||||
]);
|
||||
const manifestScopeOptions: Readonly<Record<PackageManager, ReadonlySet<string>>> = {
|
||||
pnpm: new Set(["-C", "--dir", "--filter", "--workspace-dir"]),
|
||||
npm: new Set(["--prefix", "--workspace"]),
|
||||
yarn: new Set(["--cwd"]),
|
||||
};
|
||||
const lifecycleMutationCommands = new Set([
|
||||
"add", "ci", "dedupe", "i", "install", "link", "pack", "prune", "publish",
|
||||
"rebuild", "remove", "rm", "uninstall", "unlink", "up", "update", "upgrade",
|
||||
]);
|
||||
const managerBuiltinAliases: Readonly<
|
||||
Record<PackageManager, ReadonlyMap<string, string>>
|
||||
> = {
|
||||
pnpm: new Map([["ln", "link"]]),
|
||||
npm: new Map(),
|
||||
yarn: new Map(),
|
||||
};
|
||||
const unsupportedBuiltinDispatchers: Readonly<
|
||||
Record<PackageManager, ReadonlySet<string>>
|
||||
> = {
|
||||
pnpm: new Set(["dlx", "exec"]),
|
||||
npm: new Set(["exec"]),
|
||||
yarn: new Set(["dlx", "exec", "workspace", "workspaces"]),
|
||||
};
|
||||
const lifecycleBooleanOptions: Readonly<
|
||||
Record<PackageManager, ReadonlySet<string>>
|
||||
> = {
|
||||
pnpm: new Set([
|
||||
"--dry-run", "--force", "--frozen-lockfile", "--lockfile-only",
|
||||
"--no-optional", "--prefer-frozen-lockfile", "--recursive",
|
||||
"--workspace-root", "-D", "-P", "-r", "-w",
|
||||
]),
|
||||
npm: new Set([
|
||||
"--audit", "--dry-run", "--force", "--foreground-scripts", "--fund",
|
||||
"--package-lock-only",
|
||||
]),
|
||||
yarn: new Set([
|
||||
"--check-cache", "--frozen-lockfile", "--ignore-engines",
|
||||
"--ignore-optional", "--immutable", "--immutable-cache", "--inline-builds",
|
||||
"--no-lockfile", "--non-interactive", "--pure-lockfile",
|
||||
]),
|
||||
};
|
||||
const lifecycleOptionsWithValue: Readonly<
|
||||
Record<PackageManager, ReadonlySet<string>>
|
||||
> = {
|
||||
pnpm: new Set(["--child-concurrency", "--modules-dir", "--reporter"]),
|
||||
npm: new Set(["--include", "--install-strategy", "--omit"]),
|
||||
yarn: new Set(["--mode", "--modules-folder", "--production"]),
|
||||
};
|
||||
const knownBuiltinCommands: Readonly<Record<PackageManager, ReadonlySet<string>>> = {
|
||||
pnpm: new Set([
|
||||
"audit", "config", "deploy", "dlx", "exec", "fetch", "help", "list", "ls",
|
||||
"outdated", "root", "server", "setup", "store", "view", "why",
|
||||
]),
|
||||
npm: new Set([
|
||||
"access", "audit", "bugs", "cache", "completion", "config", "diff", "docs",
|
||||
"doctor", "exec", "explore", "fund", "help", "help-search", "hook", "init",
|
||||
"list", "login", "logout", "ls", "org", "outdated", "owner", "ping", "pkg",
|
||||
"prefix", "profile", "query", "repo", "root", "search", "star", "stars",
|
||||
"team", "token", "unstar", "version", "view", "whoami",
|
||||
]),
|
||||
yarn: new Set([
|
||||
"cache", "config", "constraints", "dedupe", "dlx", "exec", "help", "info",
|
||||
"npm", "plugin", "set", "stage", "version", "why",
|
||||
]),
|
||||
};
|
||||
const exactBareSafeBuiltinCommands: Readonly<
|
||||
Record<PackageManager, ReadonlySet<string>>
|
||||
> = {
|
||||
pnpm: new Set(["audit"]),
|
||||
npm: new Set(["audit"]),
|
||||
yarn: new Set(),
|
||||
};
|
||||
const npmImplicitScripts = new Set(["restart", "start", "stop", "test"]);
|
||||
|
||||
export function validatePackageScriptGraph(
|
||||
scripts: Readonly<Record<string, string>>,
|
||||
@@ -27,21 +160,17 @@ export function validatePackageScriptGraph(
|
||||
if (/\bscripts\/run-ci-gate(?:\.[cm]?[jt]s)?\b/u.test(command)) {
|
||||
failures.push(`${scriptName} must not invoke the CI gate runner`);
|
||||
}
|
||||
if (/\bci:gate\b/u.test(command)) {
|
||||
failures.push(`${scriptName} must not invoke ci:gate`);
|
||||
const parsed = parseManagerCommands(command, scripts);
|
||||
if (parsed.unsupportedManagerSyntax) {
|
||||
failures.push(`package script manager invocation is not safely parseable: ${scriptName}`);
|
||||
}
|
||||
packageScriptInvocation.lastIndex = 0;
|
||||
const dependencies = Array.from(
|
||||
command.matchAll(packageScriptInvocation),
|
||||
(match) => match[1]!,
|
||||
).filter((dependency) => !pnpmNonScriptCommands.has(dependency));
|
||||
for (const dependency of dependencies) {
|
||||
if (dependency !== "ci:gate") {
|
||||
if (!(dependency in scripts)) {
|
||||
failures.push(`package script missing: ${scriptName} -> ${dependency}`);
|
||||
} else {
|
||||
visit(dependency);
|
||||
}
|
||||
for (const dependency of parsed.dependencies) {
|
||||
if (dependency === "ci:gate") {
|
||||
failures.push(`${scriptName} must not invoke ci:gate`);
|
||||
} else if (!(dependency in scripts)) {
|
||||
failures.push(`package script missing: ${scriptName} -> ${dependency}`);
|
||||
} else {
|
||||
visit(dependency);
|
||||
}
|
||||
}
|
||||
stack.pop();
|
||||
@@ -52,3 +181,814 @@ export function validatePackageScriptGraph(
|
||||
visit(entryScript);
|
||||
return [...new Set(failures)];
|
||||
}
|
||||
|
||||
export function validateInstallScriptPolicy(
|
||||
scripts: Readonly<Record<string, string>>,
|
||||
entryScripts: readonly string[],
|
||||
): string[] {
|
||||
const failures: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
const visit = (scriptName: string): void => {
|
||||
if (visited.has(scriptName)) return;
|
||||
visited.add(scriptName);
|
||||
const command = scripts[scriptName];
|
||||
if (command === undefined) {
|
||||
failures.push(`package script missing: ${scriptName}`);
|
||||
return;
|
||||
}
|
||||
const parsed = parseManagerCommands(command, scripts);
|
||||
if (parsed.unsafeLifecycle || parsed.unsupportedManagerSyntax) {
|
||||
failures.push(
|
||||
`install-bearing package script must use --ignore-scripts: ${scriptName}`,
|
||||
);
|
||||
}
|
||||
for (const dependency of parsed.dependencies) {
|
||||
if (dependency !== "ci:gate") visit(dependency);
|
||||
}
|
||||
};
|
||||
for (const entryScript of entryScripts) visit(entryScript);
|
||||
return [...new Set(failures)];
|
||||
}
|
||||
|
||||
export function validateNpmScopeEnvironment(
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): string[] {
|
||||
return Object.keys(environment)
|
||||
.filter((name) => npmDispatchScopeEnvironmentNames.has(name.toLowerCase()))
|
||||
.map((name) => `npm scope environment is not allowed: ${name}`);
|
||||
}
|
||||
|
||||
function parseManagerCommands(
|
||||
command: string,
|
||||
scripts: Readonly<Record<string, string>>,
|
||||
): ManagerParseResult {
|
||||
const tokenized = tokenizeShellSegments(command);
|
||||
const dependencies: string[] = [];
|
||||
let unsafeLifecycle = false;
|
||||
let unsupportedManagerSyntax = false;
|
||||
if (!tokenized) {
|
||||
return Object.freeze({
|
||||
dependencies: Object.freeze([]),
|
||||
unsafeLifecycle: false,
|
||||
unsupportedManagerSyntax: containsManagerReference(command),
|
||||
});
|
||||
}
|
||||
unsupportedManagerSyntax ||= tokenized.unsupportedControl && containsManagerReference(command);
|
||||
const npmScopeEnvironmentState: ShellNpmScopeEnvironmentState = {
|
||||
autoExport: false,
|
||||
forbidden: false,
|
||||
uncertain: false,
|
||||
};
|
||||
for (const segment of tokenized.segments) {
|
||||
const { tokens, expansionTokens } = segment;
|
||||
updateShellNpmScopeEnvironmentState(segment, npmScopeEnvironmentState);
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index]!;
|
||||
if (token === "corepack") {
|
||||
if (hasUnsafeManagerCommandPrefix(tokens, expansionTokens, index)) {
|
||||
unsupportedManagerSyntax = true;
|
||||
break;
|
||||
}
|
||||
const wrapped = tokens[index + 1];
|
||||
if (!wrapped || !isPackageManager(wrapped)) {
|
||||
unsupportedManagerSyntax = true;
|
||||
break;
|
||||
}
|
||||
const parsed = parseManagerInvocation(
|
||||
wrapped,
|
||||
tokens,
|
||||
expansionTokens,
|
||||
index + 2,
|
||||
scripts,
|
||||
hasUnsafeNpmScopeEnvironment(
|
||||
npmScopeEnvironmentState,
|
||||
tokens,
|
||||
expansionTokens,
|
||||
index,
|
||||
),
|
||||
);
|
||||
dependencies.push(...parsed.dependencies);
|
||||
unsafeLifecycle ||= parsed.unsafeLifecycle;
|
||||
unsupportedManagerSyntax ||= parsed.unsupportedManagerSyntax;
|
||||
break;
|
||||
}
|
||||
if (isPackageManager(token)) {
|
||||
if (hasUnsafeManagerCommandPrefix(tokens, expansionTokens, index)) {
|
||||
unsupportedManagerSyntax = true;
|
||||
break;
|
||||
}
|
||||
const parsed = parseManagerInvocation(
|
||||
token,
|
||||
tokens,
|
||||
expansionTokens,
|
||||
index + 1,
|
||||
scripts,
|
||||
hasUnsafeNpmScopeEnvironment(
|
||||
npmScopeEnvironmentState,
|
||||
tokens,
|
||||
expansionTokens,
|
||||
index,
|
||||
),
|
||||
);
|
||||
dependencies.push(...parsed.dependencies);
|
||||
unsafeLifecycle ||= parsed.unsafeLifecycle;
|
||||
unsupportedManagerSyntax ||= parsed.unsupportedManagerSyntax;
|
||||
break;
|
||||
}
|
||||
if (containsManagerReference(token)) {
|
||||
unsupportedManagerSyntax = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
dependencies: Object.freeze([...new Set(dependencies)]),
|
||||
unsafeLifecycle,
|
||||
unsupportedManagerSyntax,
|
||||
});
|
||||
}
|
||||
|
||||
function parseManagerInvocation(
|
||||
manager: PackageManager,
|
||||
tokens: readonly string[],
|
||||
expansionTokens: readonly boolean[],
|
||||
start: number,
|
||||
scripts: Readonly<Record<string, string>>,
|
||||
hasNpmScopeEnvironment: boolean,
|
||||
): ManagerParseResult {
|
||||
let cursor = start;
|
||||
let changesManifestScope = false;
|
||||
let hasNpmConfigAuthority = false;
|
||||
let consumedManagerSyntax = false;
|
||||
const suppression: SuppressionState = {
|
||||
effective: undefined,
|
||||
contradictory: false,
|
||||
malformed: false,
|
||||
};
|
||||
while (cursor < tokens.length && tokens[cursor]!.startsWith("-")) {
|
||||
consumedManagerSyntax = true;
|
||||
const option = tokens[cursor]!;
|
||||
const parsedSuppression = consumeSuppressionOption(
|
||||
manager,
|
||||
tokens,
|
||||
cursor,
|
||||
suppression,
|
||||
);
|
||||
if (parsedSuppression.recognized) {
|
||||
if (parsedSuppression.unsupported) return unsupportedResult();
|
||||
cursor = parsedSuppression.nextIndex;
|
||||
continue;
|
||||
}
|
||||
const equals = option.indexOf("=");
|
||||
const name = equals < 0 ? option : option.slice(0, equals);
|
||||
if (managerOptionsWithValue.has(name)) {
|
||||
changesManifestScope ||= manifestScopeOptions[manager].has(name);
|
||||
hasNpmConfigAuthority ||=
|
||||
manager === "npm" && npmIndirectConfigAuthorityOptions.has(name);
|
||||
if (equals >= 0) {
|
||||
if (option.slice(equals + 1).length === 0) return unsupportedResult();
|
||||
} else {
|
||||
cursor += 1;
|
||||
if (cursor >= tokens.length || tokens[cursor]!.startsWith("-")) {
|
||||
return unsupportedResult();
|
||||
}
|
||||
}
|
||||
} else if (managerBooleanOptions.has(name)) {
|
||||
if (equals >= 0 && !/^(?:true|false)$/u.test(option.slice(equals + 1))) {
|
||||
return unsupportedResult();
|
||||
}
|
||||
} else if (option !== "--") {
|
||||
return unsupportedResult();
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
const subcommand = tokens[cursor];
|
||||
if (!subcommand) return unsupportedResult();
|
||||
if (manager === "npm" && (hasNpmScopeEnvironment || hasNpmConfigAuthority)) {
|
||||
return unsupportedResult();
|
||||
}
|
||||
const argumentsAfterCommand = tokens.slice(cursor + 1);
|
||||
if (subcommand === "run" || subcommand === "run-script") {
|
||||
const dependency = argumentsAfterCommand[0];
|
||||
if (!dependency || dependency.startsWith("-")) return unsupportedResult();
|
||||
if (changesManifestScope) return unsupportedResult();
|
||||
if (
|
||||
manager === "npm" &&
|
||||
(expansionTokens.slice(start, cursor + 2).some(Boolean) ||
|
||||
!areNpmScriptDispatchArgumentsSupported(
|
||||
argumentsAfterCommand.slice(1),
|
||||
expansionTokens.slice(cursor + 2),
|
||||
suppression,
|
||||
))
|
||||
) {
|
||||
return unsupportedResult();
|
||||
}
|
||||
return manager === "npm"
|
||||
? npmScriptDependencyResult(dependency, scripts, suppression)
|
||||
: dependencyResult(dependency);
|
||||
}
|
||||
const canonicalSubcommand = managerBuiltinAliases[manager].get(subcommand) ?? subcommand;
|
||||
if (unsupportedBuiltinDispatchers[manager].has(canonicalSubcommand)) {
|
||||
return unsupportedResult();
|
||||
}
|
||||
if (lifecycleMutationCommands.has(canonicalSubcommand)) {
|
||||
const lifecycleArgumentsSupported = parseLifecycleArguments(
|
||||
manager,
|
||||
argumentsAfterCommand,
|
||||
suppression,
|
||||
);
|
||||
return Object.freeze({
|
||||
dependencies: Object.freeze([]),
|
||||
unsafeLifecycle:
|
||||
!lifecycleArgumentsSupported || !hasEffectiveLifecycleSuppression(suppression),
|
||||
unsupportedManagerSyntax: !lifecycleArgumentsSupported,
|
||||
});
|
||||
}
|
||||
if (knownBuiltinCommands[manager].has(canonicalSubcommand)) {
|
||||
return exactBareSafeBuiltinCommands[manager].has(canonicalSubcommand) &&
|
||||
!consumedManagerSyntax &&
|
||||
argumentsAfterCommand.length === 0
|
||||
? emptyResult()
|
||||
: unsupportedResult();
|
||||
}
|
||||
const isKnownRootScript = Object.prototype.hasOwnProperty.call(scripts, subcommand);
|
||||
const supportsImplicit = /^[A-Za-z0-9:_-]+$/u.test(subcommand) && (
|
||||
((manager === "pnpm" || manager === "yarn") && isKnownRootScript) ||
|
||||
(manager === "npm" && npmImplicitScripts.has(subcommand))
|
||||
);
|
||||
if (supportsImplicit) {
|
||||
if (changesManifestScope) return unsupportedResult();
|
||||
if (
|
||||
manager === "npm" &&
|
||||
(expansionTokens.slice(start, cursor + 1).some(Boolean) ||
|
||||
!areNpmScriptDispatchArgumentsSupported(
|
||||
argumentsAfterCommand,
|
||||
expansionTokens.slice(cursor + 1),
|
||||
suppression,
|
||||
))
|
||||
) {
|
||||
return unsupportedResult();
|
||||
}
|
||||
return manager === "npm"
|
||||
? npmScriptDependencyResult(subcommand, scripts, suppression)
|
||||
: dependencyResult(subcommand);
|
||||
}
|
||||
return unsupportedResult();
|
||||
}
|
||||
|
||||
function areNpmScriptDispatchArgumentsSupported(
|
||||
tokens: readonly string[],
|
||||
expansionTokens: readonly boolean[],
|
||||
suppression: SuppressionState,
|
||||
): boolean {
|
||||
let index = 0;
|
||||
while (index < tokens.length) {
|
||||
const token = tokens[index]!;
|
||||
if (token === "--") return true;
|
||||
if (expansionTokens[index]) return false;
|
||||
if (!token.startsWith("-")) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedSuppression = consumeSuppressionOption(
|
||||
"npm",
|
||||
tokens,
|
||||
index,
|
||||
suppression,
|
||||
);
|
||||
if (parsedSuppression.recognized) {
|
||||
if (parsedSuppression.unsupported) return false;
|
||||
index = parsedSuppression.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
const equals = token.indexOf("=");
|
||||
const name = equals < 0 ? token : token.slice(0, equals);
|
||||
const isShortWorkspaceOption = token === "-w" || /^-w(?:=)?.+/u.test(token);
|
||||
if (npmScriptDispatchScopeOptions.has(name) || isShortWorkspaceOption) {
|
||||
return false;
|
||||
}
|
||||
if (!npmScriptDispatchBooleanOptions.has(name)) return false;
|
||||
if (equals >= 0 && !/^(?:true|false)$/u.test(token.slice(equals + 1))) {
|
||||
return false;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasUnsafeNpmScopeEnvironment(
|
||||
state: Readonly<ShellNpmScopeEnvironmentState>,
|
||||
tokens: readonly string[],
|
||||
expansionTokens: readonly boolean[],
|
||||
commandIndex: number,
|
||||
): boolean {
|
||||
return state.forbidden || state.uncertain ||
|
||||
hasUnsafeImmediateNpmScopeEnvironment(tokens, expansionTokens, commandIndex);
|
||||
}
|
||||
|
||||
function hasUnsafeManagerCommandPrefix(
|
||||
tokens: readonly string[],
|
||||
expansionTokens: readonly boolean[],
|
||||
commandIndex: number,
|
||||
): boolean {
|
||||
const prefix = parseShellCommandPrefix(tokens, expansionTokens);
|
||||
if (prefix.uncertain) return true;
|
||||
let cursor = prefix.commandIndex;
|
||||
if (cursor === commandIndex) return false;
|
||||
if (cursor > commandIndex ||
|
||||
!isEnvironmentCommand(tokens[cursor], expansionTokens[cursor] ?? false)) {
|
||||
return true;
|
||||
}
|
||||
return parseEnvironmentCommandPrefix(
|
||||
tokens,
|
||||
expansionTokens,
|
||||
cursor,
|
||||
commandIndex,
|
||||
).uncertain;
|
||||
}
|
||||
|
||||
function hasUnsafeImmediateNpmScopeEnvironment(
|
||||
tokens: readonly string[],
|
||||
expansionTokens: readonly boolean[],
|
||||
commandIndex: number,
|
||||
): boolean {
|
||||
const prefix = parseShellCommandPrefix(tokens, expansionTokens);
|
||||
if (prefix.uncertain || prefix.assignments.some(
|
||||
({ name }) => name !== null && isNpmScopeEnvironmentName(name),
|
||||
)) return true;
|
||||
let cursor = prefix.commandIndex;
|
||||
if (cursor === commandIndex) return false;
|
||||
if (!isEnvironmentCommand(tokens[cursor], expansionTokens[cursor] ?? false)) return false;
|
||||
const environmentPrefix = parseEnvironmentCommandPrefix(
|
||||
tokens,
|
||||
expansionTokens,
|
||||
cursor,
|
||||
commandIndex,
|
||||
);
|
||||
return environmentPrefix.uncertain || environmentPrefix.assignmentNames.some(
|
||||
(name) => isNpmScopeEnvironmentName(name),
|
||||
);
|
||||
}
|
||||
|
||||
function parseEnvironmentCommandPrefix(
|
||||
tokens: readonly string[],
|
||||
expansionTokens: readonly boolean[],
|
||||
start: number,
|
||||
commandIndex: number,
|
||||
): EnvironmentCommandPrefix {
|
||||
const assignmentNames: string[] = [];
|
||||
let cursor = start + 1;
|
||||
let uncertain = false;
|
||||
|
||||
while (cursor < commandIndex && tokens[cursor]!.startsWith("-")) {
|
||||
const option = tokens[cursor]!;
|
||||
if (expansionTokens[cursor]) uncertain = true;
|
||||
if (option === "--") {
|
||||
cursor += 1;
|
||||
break;
|
||||
}
|
||||
if (option === "-i" || option === "--ignore-environment") {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
if (option === "-u" || option === "--unset") {
|
||||
cursor += 1;
|
||||
if (cursor >= commandIndex || tokens[cursor]!.startsWith("-")) {
|
||||
uncertain = true;
|
||||
break;
|
||||
}
|
||||
uncertain ||= expansionTokens[cursor] ?? false;
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^--unset=.+/u.test(option)) {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
uncertain = true;
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
while (cursor < commandIndex) {
|
||||
const token = tokens[cursor]!;
|
||||
if (hasDynamicAssignmentName(token, expansionTokens[cursor] ?? false)) {
|
||||
uncertain = true;
|
||||
}
|
||||
const assignmentName = parseEnvironmentAssignmentName(token);
|
||||
if (assignmentName) assignmentNames.push(assignmentName);
|
||||
else uncertain = true;
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
assignmentNames: Object.freeze(assignmentNames),
|
||||
uncertain,
|
||||
});
|
||||
}
|
||||
|
||||
function parseShellCommandPrefix(
|
||||
tokens: readonly string[],
|
||||
expansionTokens: readonly boolean[],
|
||||
): ShellCommandPrefix {
|
||||
const assignments: Array<{
|
||||
dynamicName: boolean;
|
||||
name: string | null;
|
||||
}> = [];
|
||||
let cursor = 0;
|
||||
let uncertain = false;
|
||||
while (cursor < tokens.length) {
|
||||
const token = tokens[cursor]!;
|
||||
const name = parseAssignmentName(token);
|
||||
const dynamicName = hasDynamicAssignmentName(
|
||||
token,
|
||||
expansionTokens[cursor] ?? false,
|
||||
);
|
||||
if (!name && !dynamicName) break;
|
||||
assignments.push({ dynamicName, name });
|
||||
uncertain ||= dynamicName;
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
while (cursor < tokens.length) {
|
||||
const wrapper = tokens[cursor];
|
||||
if (expansionTokens[cursor]) {
|
||||
uncertain = true;
|
||||
break;
|
||||
}
|
||||
if (wrapper !== "command" && wrapper !== "exec") break;
|
||||
cursor += 1;
|
||||
while (cursor < tokens.length && tokens[cursor]!.startsWith("-")) {
|
||||
const option = tokens[cursor]!;
|
||||
if (option === "--") {
|
||||
cursor += 1;
|
||||
break;
|
||||
}
|
||||
if (wrapper === "command" && option === "-p") {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
uncertain = true;
|
||||
cursor += 1;
|
||||
if (wrapper === "exec" && option === "-a" && cursor < tokens.length) {
|
||||
cursor += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (expansionTokens[cursor]) uncertain = true;
|
||||
return Object.freeze({
|
||||
assignments: Object.freeze(assignments.map((assignment) => Object.freeze(assignment))),
|
||||
commandIndex: cursor,
|
||||
uncertain,
|
||||
});
|
||||
}
|
||||
|
||||
function updateShellNpmScopeEnvironmentState(
|
||||
segment: TokenizedShellSegment,
|
||||
state: ShellNpmScopeEnvironmentState,
|
||||
): void {
|
||||
const { tokens, expansionTokens } = segment;
|
||||
const prefix = parseShellCommandPrefix(tokens, expansionTokens);
|
||||
state.uncertain ||= prefix.uncertain;
|
||||
const command = tokens[prefix.commandIndex];
|
||||
if (!command) {
|
||||
for (const assignment of prefix.assignments) {
|
||||
if (state.autoExport && assignment.name &&
|
||||
isNpmScopeEnvironmentName(assignment.name)) {
|
||||
state.forbidden = true;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "eval" || command === "." || command === "source") {
|
||||
state.uncertain = true;
|
||||
return;
|
||||
}
|
||||
if (command === "unset" || command === "typeset" || command === "declare" ||
|
||||
command === "local" || command === "readonly") {
|
||||
state.uncertain = true;
|
||||
return;
|
||||
}
|
||||
if (command === "set") {
|
||||
if (tokens.slice(prefix.commandIndex + 1).includes("-a")) state.autoExport = true;
|
||||
if (tokens.slice(prefix.commandIndex + 1).includes("+a")) {
|
||||
state.autoExport = false;
|
||||
state.uncertain = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (command === "export") {
|
||||
let cursor = prefix.commandIndex + 1;
|
||||
for (; cursor < tokens.length; cursor += 1) {
|
||||
const token = tokens[cursor]!;
|
||||
if (token === "--") continue;
|
||||
if (token === "-n" || token.startsWith("-")) {
|
||||
state.uncertain = true;
|
||||
continue;
|
||||
}
|
||||
const assignmentName = parseAssignmentName(token);
|
||||
const bareName = /^[A-Za-z_][A-Za-z0-9_]*$/u.test(token) ? token : null;
|
||||
if (assignmentName || bareName) {
|
||||
if (isNpmScopeEnvironmentName(assignmentName ?? bareName!)) {
|
||||
state.forbidden = true;
|
||||
}
|
||||
} else if (hasDynamicAssignmentName(token, expansionTokens[cursor] ?? false) ||
|
||||
expansionTokens[cursor]) {
|
||||
state.uncertain = true;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function isEnvironmentCommand(token: string | undefined, hasExpansion: boolean): boolean {
|
||||
if (!token || hasExpansion) return false;
|
||||
return token.split("/").at(-1) === "env";
|
||||
}
|
||||
|
||||
function parseEnvironmentAssignmentName(token: string): string | null {
|
||||
const equals = token.indexOf("=");
|
||||
return equals > 0 ? token.slice(0, equals) : null;
|
||||
}
|
||||
|
||||
function isNpmScopeEnvironmentName(name: string): boolean {
|
||||
return npmDispatchScopeEnvironmentNames.has(name.toLowerCase());
|
||||
}
|
||||
|
||||
function hasDynamicAssignmentName(token: string, hasExpansion: boolean): boolean {
|
||||
const equals = token.indexOf("=");
|
||||
return hasExpansion && equals > 0 && parseAssignmentName(token) === null;
|
||||
}
|
||||
|
||||
function parseAssignmentName(token: string): string | null {
|
||||
return /^([A-Za-z_][A-Za-z0-9_]*)=/u.exec(token)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function parseLifecycleArguments(
|
||||
manager: PackageManager,
|
||||
tokens: readonly string[],
|
||||
suppression: SuppressionState,
|
||||
): boolean {
|
||||
let cursor = 0;
|
||||
while (cursor < tokens.length) {
|
||||
const token = tokens[cursor]!;
|
||||
if (!token.startsWith("-")) {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
const parsedSuppression = consumeSuppressionOption(
|
||||
manager,
|
||||
tokens,
|
||||
cursor,
|
||||
suppression,
|
||||
);
|
||||
if (parsedSuppression.recognized) {
|
||||
if (parsedSuppression.unsupported) return false;
|
||||
cursor = parsedSuppression.nextIndex;
|
||||
continue;
|
||||
}
|
||||
const parsedOption = consumeAllowedLifecycleOption(manager, tokens, cursor);
|
||||
if (parsedOption === null) return false;
|
||||
cursor = parsedOption;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function consumeSuppressionOption(
|
||||
manager: PackageManager,
|
||||
tokens: readonly string[],
|
||||
index: number,
|
||||
state: SuppressionState,
|
||||
): Readonly<{ recognized: boolean; unsupported: boolean; nextIndex: number }> {
|
||||
const token = tokens[index]!;
|
||||
if (token === "--no-ignore-scripts") {
|
||||
recordSuppression(state, false);
|
||||
return { recognized: true, unsupported: false, nextIndex: index + 1 };
|
||||
}
|
||||
const equalsForms = ["--ignore-scripts=", "--config.ignore-scripts="] as const;
|
||||
for (const prefix of equalsForms) {
|
||||
if (!token.startsWith(prefix)) continue;
|
||||
if (prefix.startsWith("--config.") && manager !== "pnpm") {
|
||||
state.malformed = true;
|
||||
return { recognized: true, unsupported: true, nextIndex: index + 1 };
|
||||
}
|
||||
const raw = token.slice(prefix.length);
|
||||
if (raw !== "true" && raw !== "false") {
|
||||
state.malformed = true;
|
||||
return { recognized: true, unsupported: true, nextIndex: index + 1 };
|
||||
}
|
||||
recordSuppression(state, raw === "true");
|
||||
return { recognized: true, unsupported: false, nextIndex: index + 1 };
|
||||
}
|
||||
if (token !== "--ignore-scripts" && token !== "--config.ignore-scripts") {
|
||||
return { recognized: false, unsupported: false, nextIndex: index };
|
||||
}
|
||||
if (token === "--config.ignore-scripts" && manager !== "pnpm") {
|
||||
state.malformed = true;
|
||||
return { recognized: true, unsupported: true, nextIndex: index + 1 };
|
||||
}
|
||||
const next = tokens[index + 1];
|
||||
if (next === "true" || next === "false") {
|
||||
const supportsSplitValue = manager === "npm" || manager === "pnpm";
|
||||
if (!supportsSplitValue) {
|
||||
state.malformed = true;
|
||||
return { recognized: true, unsupported: true, nextIndex: index + 2 };
|
||||
}
|
||||
recordSuppression(state, next === "true");
|
||||
return { recognized: true, unsupported: false, nextIndex: index + 2 };
|
||||
}
|
||||
recordSuppression(state, true);
|
||||
return { recognized: true, unsupported: false, nextIndex: index + 1 };
|
||||
}
|
||||
|
||||
function recordSuppression(
|
||||
state: SuppressionState,
|
||||
value: boolean,
|
||||
): void {
|
||||
if (state.effective !== undefined && state.effective !== value) {
|
||||
state.contradictory = true;
|
||||
}
|
||||
state.effective = value;
|
||||
}
|
||||
|
||||
function hasEffectiveLifecycleSuppression(state: SuppressionState): boolean {
|
||||
return state.effective === true && !state.contradictory && !state.malformed;
|
||||
}
|
||||
|
||||
function consumeAllowedLifecycleOption(
|
||||
manager: PackageManager,
|
||||
tokens: readonly string[],
|
||||
index: number,
|
||||
): number | null {
|
||||
const option = tokens[index]!;
|
||||
const equals = option.indexOf("=");
|
||||
const name = equals < 0 ? option : option.slice(0, equals);
|
||||
const booleanOption =
|
||||
managerBooleanOptions.has(name) || lifecycleBooleanOptions[manager].has(name);
|
||||
if (booleanOption) {
|
||||
if (equals >= 0 && !/^(?:true|false)$/u.test(option.slice(equals + 1))) return null;
|
||||
return index + 1;
|
||||
}
|
||||
const valuedOption =
|
||||
managerOptionsWithValue.has(name) || lifecycleOptionsWithValue[manager].has(name);
|
||||
if (!valuedOption) return null;
|
||||
if (equals >= 0) return option.slice(equals + 1).length > 0 ? index + 1 : null;
|
||||
const value = tokens[index + 1];
|
||||
if (!value || value.startsWith("-")) return null;
|
||||
return index + 2;
|
||||
}
|
||||
|
||||
function dependencyResult(dependency: string): ManagerParseResult {
|
||||
return dependenciesResult([dependency]);
|
||||
}
|
||||
|
||||
function npmScriptDependencyResult(
|
||||
dependency: string,
|
||||
scripts: Readonly<Record<string, string>>,
|
||||
suppression: SuppressionState,
|
||||
): ManagerParseResult {
|
||||
if (hasEffectiveLifecycleSuppression(suppression)) {
|
||||
return dependenciesResult([dependency]);
|
||||
}
|
||||
return dependenciesResult(
|
||||
[`pre${dependency}`, dependency, `post${dependency}`]
|
||||
.filter((scriptName) => scriptName === dependency || scriptName in scripts),
|
||||
);
|
||||
}
|
||||
|
||||
function dependenciesResult(dependencies: readonly string[]): ManagerParseResult {
|
||||
return Object.freeze({
|
||||
dependencies: Object.freeze([...dependencies]),
|
||||
unsafeLifecycle: false,
|
||||
unsupportedManagerSyntax: false,
|
||||
});
|
||||
}
|
||||
|
||||
function emptyResult(): ManagerParseResult {
|
||||
return Object.freeze({
|
||||
dependencies: Object.freeze([]),
|
||||
unsafeLifecycle: false,
|
||||
unsupportedManagerSyntax: false,
|
||||
});
|
||||
}
|
||||
|
||||
function unsupportedResult(): ManagerParseResult {
|
||||
return Object.freeze({
|
||||
dependencies: Object.freeze([]),
|
||||
unsafeLifecycle: false,
|
||||
unsupportedManagerSyntax: true,
|
||||
});
|
||||
}
|
||||
|
||||
function isPackageManager(value: string): value is PackageManager {
|
||||
return managerNames.has(value as PackageManager);
|
||||
}
|
||||
|
||||
function containsManagerReference(value: string): boolean {
|
||||
return /(?:^|[^A-Za-z0-9_-])(?:corepack|pnpm|npm|yarn)(?:[^A-Za-z0-9_-]|$)/u
|
||||
.test(value);
|
||||
}
|
||||
|
||||
function tokenizeShellSegments(command: string): Readonly<{
|
||||
segments: readonly TokenizedShellSegment[];
|
||||
unsupportedControl: boolean;
|
||||
}> | null {
|
||||
const segments: Array<{ tokens: string[]; expansionTokens: boolean[] }> = [
|
||||
{ tokens: [], expansionTokens: [] },
|
||||
];
|
||||
let token = "";
|
||||
let tokenHasExpansion = false;
|
||||
let quote: "'" | '"' | null = null;
|
||||
let escaping = false;
|
||||
let unsupportedControl = false;
|
||||
const pushToken = (): void => {
|
||||
if (token.length > 0) {
|
||||
segments.at(-1)!.tokens.push(token);
|
||||
segments.at(-1)!.expansionTokens.push(tokenHasExpansion);
|
||||
}
|
||||
token = "";
|
||||
tokenHasExpansion = false;
|
||||
};
|
||||
const pushSegment = (): void => {
|
||||
pushToken();
|
||||
if (segments.at(-1)!.tokens.length > 0) {
|
||||
segments.push({ tokens: [], expansionTokens: [] });
|
||||
}
|
||||
};
|
||||
for (let index = 0; index < command.length; index += 1) {
|
||||
const character = command[index]!;
|
||||
if (escaping) {
|
||||
token += character;
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
if (character === "\\" && quote !== "'") {
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (character === quote) quote = null;
|
||||
else {
|
||||
if (quote === '"' && character === "$") tokenHasExpansion = true;
|
||||
token += character;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (character === "'" || character === '"') {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
if (character === "`" || (character === "$" && command[index + 1] === "(")) {
|
||||
unsupportedControl = true;
|
||||
if (character === "$") tokenHasExpansion = true;
|
||||
token += character;
|
||||
continue;
|
||||
}
|
||||
if (character === "$" || character === "*" || character === "?" || character === "[") {
|
||||
tokenHasExpansion = true;
|
||||
}
|
||||
if (character === "#") {
|
||||
unsupportedControl = true;
|
||||
pushToken();
|
||||
while (
|
||||
index + 1 < command.length &&
|
||||
command[index + 1] !== "\n" &&
|
||||
command[index + 1] !== "\r"
|
||||
) {
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (character === "<" || character === ">" || character === "(" || character === ")") {
|
||||
unsupportedControl = true;
|
||||
token += character;
|
||||
continue;
|
||||
}
|
||||
if (/\s/u.test(character)) {
|
||||
pushToken();
|
||||
if (character === "\n" || character === "\r") pushSegment();
|
||||
continue;
|
||||
}
|
||||
if (character === ";" || character === "|" || character === "&") {
|
||||
pushSegment();
|
||||
if (command[index + 1] === character) index += 1;
|
||||
continue;
|
||||
}
|
||||
token += character;
|
||||
}
|
||||
if (quote || escaping) return null;
|
||||
pushToken();
|
||||
return Object.freeze({
|
||||
segments: Object.freeze(
|
||||
segments
|
||||
.filter((segment) => segment.tokens.length > 0)
|
||||
.map((segment) => Object.freeze({
|
||||
tokens: Object.freeze(segment.tokens),
|
||||
expansionTokens: Object.freeze(segment.expansionTokens),
|
||||
})),
|
||||
),
|
||||
unsupportedControl,
|
||||
});
|
||||
}
|
||||
|
||||
+304
-29
@@ -8,7 +8,9 @@ import {
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
readdir,
|
||||
rm,
|
||||
rmdir,
|
||||
stat,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
} from "../contracts/promotion-artifacts.ts";
|
||||
import {
|
||||
evaluatePromotionEvidence,
|
||||
assertDistinctProviderTrust,
|
||||
providerPublicKeyFingerprint,
|
||||
providerVerificationArtifactSchema,
|
||||
PROMOTION_VERIFIER_ID,
|
||||
@@ -28,6 +31,7 @@ import {
|
||||
vulnerabilityProviderReportSchema,
|
||||
type ProviderTrust,
|
||||
} from "./provider-evidence.ts";
|
||||
import { verifyExactPromotionBundle } from "./exact-promotion-bundle.ts";
|
||||
import {
|
||||
captureCiCandidateArchive,
|
||||
withVerifiedCapturedCandidate,
|
||||
@@ -35,7 +39,8 @@ import {
|
||||
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
||||
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
||||
|
||||
type StagedFile = Readonly<{
|
||||
|
||||
export type StagedFile = Readonly<{
|
||||
name: PromotedFileName;
|
||||
bytes: Buffer;
|
||||
sha256: string;
|
||||
@@ -45,6 +50,7 @@ export type FinalizedPromotion = Readonly<{
|
||||
stagingRoot: string;
|
||||
cleanupToken: string;
|
||||
runnerTempIdentity: Readonly<{ dev: number; ino: number }>;
|
||||
stagingIdentity: Readonly<{ dev: number; ino: number }>;
|
||||
files: readonly Readonly<{ name: PromotedFileName; sha256: string }>[];
|
||||
}>;
|
||||
|
||||
@@ -69,6 +75,9 @@ export async function finalizeVerifiedPromotion(input: Readonly<{
|
||||
afterCapture?: () => Promise<void>;
|
||||
beforePublish?: () => Promise<void>;
|
||||
afterStagingWrite?: () => Promise<void>;
|
||||
afterFileWrite?: (name: PromotedFileName) => Promise<void>;
|
||||
beforeSeal?: () => Promise<void>;
|
||||
afterMkdirBeforeOpen?: (stagingRoot: string) => Promise<void>;
|
||||
}> = {}): Promise<FinalizedPromotion> {
|
||||
const root = path.resolve(input.repositoryRoot);
|
||||
const capturedArchive = await (dependencies.captureArchive ?? captureCiCandidateArchive)({
|
||||
@@ -92,14 +101,14 @@ export async function finalizeVerifiedPromotion(input: Readonly<{
|
||||
input.provenanceKeyId,
|
||||
provenanceKeyBytes,
|
||||
);
|
||||
assertDistinctProviderTrust({ vulnerabilityTrust, provenanceTrust });
|
||||
const vulnerabilityReport = vulnerabilityProviderReportSchema.parse(
|
||||
parseJson(vulnerabilityBytes),
|
||||
);
|
||||
const provenanceAttestation = provenanceProviderAttestationSchema.parse(
|
||||
parseJson(provenanceBytes),
|
||||
);
|
||||
const now = (dependencies.nowEpochMs ?? Date.now)();
|
||||
const verifiedAt = new Date(now).toISOString();
|
||||
const nowEpochMs = dependencies.nowEpochMs ?? Date.now;
|
||||
|
||||
const generated = await withVerifiedCapturedCandidate({
|
||||
captured: capturedArchive,
|
||||
@@ -128,6 +137,14 @@ export async function finalizeVerifiedPromotion(input: Readonly<{
|
||||
distSha256: manifest.distSha256,
|
||||
lockfileSha256: manifest.lockfileSha256,
|
||||
},
|
||||
secretScanAttestation: {
|
||||
status: "PASS" as const,
|
||||
localEvidenceAssessmentSha256: local.identity.assessmentSha256,
|
||||
sourceSetSha256: local.identity.sourceSetSha256,
|
||||
policySha256: local.identity.secretScan.policySha256,
|
||||
sarifSha256: local.identity.secretScan.sarifSha256,
|
||||
scanInputSha256: local.identity.secretScan.scanInputSha256,
|
||||
},
|
||||
vulnerabilityInvocationNonce: input.vulnerabilityInvocationNonce,
|
||||
provenanceInvocationNonce: input.provenanceInvocationNonce,
|
||||
} as const;
|
||||
@@ -138,7 +155,7 @@ export async function finalizeVerifiedPromotion(input: Readonly<{
|
||||
provenanceAttestation,
|
||||
vulnerabilityTrust,
|
||||
provenanceTrust,
|
||||
nowEpochMs: () => now,
|
||||
nowEpochMs,
|
||||
});
|
||||
if (reevaluated.status !== "PASS") {
|
||||
throw new Error(
|
||||
@@ -154,8 +171,10 @@ export async function finalizeVerifiedPromotion(input: Readonly<{
|
||||
vulnerabilityKeyFingerprint: vulnerabilityTrust.publicKeyFingerprint,
|
||||
provenanceKeyId: provenanceTrust.keyId,
|
||||
provenanceKeyFingerprint: provenanceTrust.publicKeyFingerprint,
|
||||
secretScanAttestation: expected.secretScanAttestation,
|
||||
} as const;
|
||||
const trustDigest = trustPolicySha256({ vulnerabilityTrust, provenanceTrust });
|
||||
const verifiedAt = new Date(nowEpochMs()).toISOString();
|
||||
const common = {
|
||||
schemaVersion: 3 as const,
|
||||
verifiedAt,
|
||||
@@ -188,6 +207,15 @@ export async function finalizeVerifiedPromotion(input: Readonly<{
|
||||
return Object.freeze({
|
||||
providerRecordBytes,
|
||||
promotionRecordBytes: canonicalJsonBytes(promotionRecord),
|
||||
exactExpected: Object.freeze({
|
||||
run: expected.run,
|
||||
sourceRevision: expected.source.revision,
|
||||
sourceSetSha256: expected.source.sourceSetSha256,
|
||||
archiveSha256: expected.candidate.archiveSha256,
|
||||
bundleSha256: expected.candidate.bundleSha256,
|
||||
distSha256: expected.candidate.distSha256,
|
||||
lockfileSha256: expected.candidate.lockfileSha256,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -206,12 +234,35 @@ export async function finalizeVerifiedPromotion(input: Readonly<{
|
||||
throw new Error("promotion exact-five canonical file order drift");
|
||||
}
|
||||
await dependencies.beforePublish?.();
|
||||
return publishPrivateStaging(
|
||||
await verifyExactPromotionBundle(
|
||||
Object.fromEntries(stagedFiles.map(({ name, bytes }) => [name, bytes])),
|
||||
{
|
||||
vulnerabilityTrust,
|
||||
provenanceTrust,
|
||||
expected: generated.exactExpected,
|
||||
nowEpochMs,
|
||||
},
|
||||
);
|
||||
return publishPrivatePromotionStaging(
|
||||
input.runnerTempRoot,
|
||||
input.expectedRun,
|
||||
stagedFiles,
|
||||
dependencies.randomBytes ?? cryptoRandomBytes,
|
||||
dependencies.afterStagingWrite,
|
||||
dependencies.afterFileWrite,
|
||||
async (capturedFiles) => {
|
||||
await verifyExactPromotionBundle(
|
||||
capturedFiles,
|
||||
{
|
||||
vulnerabilityTrust,
|
||||
provenanceTrust,
|
||||
expected: generated.exactExpected,
|
||||
nowEpochMs,
|
||||
},
|
||||
);
|
||||
},
|
||||
dependencies.afterMkdirBeforeOpen,
|
||||
dependencies.beforeSeal,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -222,6 +273,7 @@ export async function cleanupFinalizedPromotion(input: Readonly<{
|
||||
stagingRoot: string;
|
||||
cleanupToken: string;
|
||||
runnerTempIdentity: Readonly<{ dev: number; ino: number }>;
|
||||
stagingIdentity: Readonly<{ dev: number; ino: number }>;
|
||||
}>, dependencies: Readonly<{
|
||||
beforeRemove?: () => Promise<void>;
|
||||
}> = {}): Promise<void> {
|
||||
@@ -234,6 +286,10 @@ export async function cleanupFinalizedPromotion(input: Readonly<{
|
||||
input.runnerTempIdentity.dev <= 0 ||
|
||||
!Number.isSafeInteger(input.runnerTempIdentity.ino) ||
|
||||
input.runnerTempIdentity.ino <= 0
|
||||
|| !Number.isSafeInteger(input.stagingIdentity.dev)
|
||||
|| input.stagingIdentity.dev <= 0
|
||||
|| !Number.isSafeInteger(input.stagingIdentity.ino)
|
||||
|| input.stagingIdentity.ino <= 0
|
||||
) {
|
||||
throw new TypeError("promotion cleanup root/token mismatch");
|
||||
}
|
||||
@@ -260,10 +316,33 @@ export async function cleanupFinalizedPromotion(input: Readonly<{
|
||||
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
||||
throw new TypeError("promotion cleanup leaf is unsafe");
|
||||
}
|
||||
await dependencies.beforeRemove?.();
|
||||
const visibleParent = await lstat(parent);
|
||||
assertRunnerTempIdentity(visibleParent, input.runnerTempIdentity);
|
||||
await rm(descriptorExpected, { recursive: true, force: true });
|
||||
assertStagingIdentity(metadata, input.stagingIdentity);
|
||||
const stagingHandle = await open(
|
||||
descriptorExpected,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
try {
|
||||
const stagingDescriptorRoot = `/proc/self/fd/${stagingHandle.fd}`;
|
||||
assertStagingIdentity(await stat(stagingDescriptorRoot), input.stagingIdentity);
|
||||
const names = (await readdir(stagingDescriptorRoot)).sort(asciiCompare);
|
||||
if (
|
||||
JSON.stringify(names) !==
|
||||
JSON.stringify([...PROMOTED_FILE_NAMES].sort(asciiCompare))
|
||||
) {
|
||||
throw new Error("promotion cleanup leaf does not contain the exact five files");
|
||||
}
|
||||
await dependencies.beforeRemove?.();
|
||||
const visibleParent = await lstat(parent);
|
||||
assertRunnerTempIdentity(visibleParent, input.runnerTempIdentity);
|
||||
for (const name of PROMOTED_FILE_NAMES) {
|
||||
await rm(path.join(stagingDescriptorRoot, name), { force: false });
|
||||
}
|
||||
assertStagingIdentity(await stat(stagingDescriptorRoot), input.stagingIdentity);
|
||||
assertStagingIdentity(await lstat(descriptorExpected), input.stagingIdentity);
|
||||
await rmdir(descriptorExpected);
|
||||
} finally {
|
||||
await stagingHandle.close();
|
||||
}
|
||||
const afterParent = await lstat(parent);
|
||||
assertRunnerTempIdentity(afterParent, input.runnerTempIdentity);
|
||||
} finally {
|
||||
@@ -271,13 +350,29 @@ export async function cleanupFinalizedPromotion(input: Readonly<{
|
||||
}
|
||||
}
|
||||
|
||||
async function publishPrivateStaging(
|
||||
export async function publishPrivatePromotionStaging(
|
||||
runnerTempRoot: string,
|
||||
run: Readonly<{ id: string; attempt: number }>,
|
||||
files: readonly StagedFile[],
|
||||
randomBytes: (bytes: number) => Buffer,
|
||||
afterStagingWrite?: () => Promise<void>,
|
||||
afterFileWrite?: (name: PromotedFileName) => Promise<void>,
|
||||
sealStagedFiles?: (files: Readonly<Record<PromotedFileName, Buffer>>) => Promise<void>,
|
||||
afterMkdirBeforeOpen?: (stagingRoot: string) => Promise<void>,
|
||||
beforeSeal?: () => Promise<void>,
|
||||
): Promise<FinalizedPromotion> {
|
||||
if (
|
||||
JSON.stringify(files.map(({ name }) => name)) !==
|
||||
JSON.stringify(PROMOTED_FILE_NAMES) ||
|
||||
files.some(
|
||||
({ bytes, sha256: digest }) =>
|
||||
!Buffer.isBuffer(bytes) ||
|
||||
!/^[a-f0-9]{64}$/u.test(digest) ||
|
||||
sha256(bytes) !== digest,
|
||||
)
|
||||
) {
|
||||
throw new TypeError("private promotion staging requires the canonical exact-five bytes");
|
||||
}
|
||||
const parentPath = path.resolve(runnerTempRoot);
|
||||
const before = await lstat(parentPath);
|
||||
if (!before.isDirectory() || before.isSymbolicLink()) {
|
||||
@@ -298,14 +393,119 @@ async function publishPrivateStaging(
|
||||
const descriptorStaging = path.join(descriptorRoot, cleanupToken);
|
||||
const visibleStaging = path.join(parentPath, cleanupToken);
|
||||
let ownsStaging = false;
|
||||
let stagingHandle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
let createdStagingIdentity: Readonly<{ dev: number; ino: number }> | undefined;
|
||||
let stagingIdentity: Readonly<{ dev: number; ino: number }> | undefined;
|
||||
let openedIdentityVerified = false;
|
||||
const cleanup = async (primaryFailure?: unknown): Promise<void> => {
|
||||
const cleanupFailures: unknown[] = [];
|
||||
const attemptCleanup = async (operation: () => Promise<void>): Promise<void> => {
|
||||
try {
|
||||
await operation();
|
||||
} catch (error) {
|
||||
cleanupFailures.push(error);
|
||||
}
|
||||
};
|
||||
if (ownsStaging && openedIdentityVerified && stagingHandle && stagingIdentity) {
|
||||
const ownedIdentity = stagingIdentity;
|
||||
const stagingDescriptorRoot = `/proc/self/fd/${stagingHandle.fd}`;
|
||||
const removals = await Promise.allSettled(
|
||||
files.map(({ name }) => rm(path.join(stagingDescriptorRoot, name), { force: true })),
|
||||
);
|
||||
cleanupFailures.push(
|
||||
...removals.flatMap((result) =>
|
||||
result.status === "rejected" ? [result.reason] : [],
|
||||
),
|
||||
);
|
||||
await attemptCleanup(async () => {
|
||||
let visible;
|
||||
try {
|
||||
visible = await lstat(descriptorStaging);
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) return;
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
visible.isDirectory() &&
|
||||
!visible.isSymbolicLink() &&
|
||||
visible.dev === ownedIdentity.dev &&
|
||||
visible.ino === ownedIdentity.ino
|
||||
) {
|
||||
await rmdir(descriptorStaging);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (stagingHandle) {
|
||||
const ownedHandle = stagingHandle;
|
||||
await attemptCleanup(async () => ownedHandle.close());
|
||||
}
|
||||
await attemptCleanup(async () => parentHandle.close());
|
||||
if (cleanupFailures.length > 0) {
|
||||
throw new AggregateError(
|
||||
primaryFailure === undefined
|
||||
? cleanupFailures
|
||||
: [primaryFailure, ...cleanupFailures],
|
||||
primaryFailure instanceof Error
|
||||
? `${primaryFailure.message}; promotion staging cleanup also failed`
|
||||
: "promotion staging cleanup failed",
|
||||
{ cause: cleanupFailures.at(-1) },
|
||||
);
|
||||
}
|
||||
};
|
||||
let finalizedPromotion: FinalizedPromotion;
|
||||
try {
|
||||
const procMetadata = await stat(descriptorRoot);
|
||||
if (!procMetadata.isDirectory()) throw new Error("descriptor-relative staging is unavailable");
|
||||
await mkdir(descriptorStaging, { mode: 0o700 });
|
||||
ownsStaging = true;
|
||||
const createdStaging = await lstat(descriptorStaging);
|
||||
if (!createdStaging.isDirectory() || createdStaging.isSymbolicLink()) {
|
||||
throw new Error("created promotion staging leaf is unsafe");
|
||||
}
|
||||
createdStagingIdentity = Object.freeze({
|
||||
dev: createdStaging.dev,
|
||||
ino: createdStaging.ino,
|
||||
});
|
||||
await afterMkdirBeforeOpen?.(visibleStaging);
|
||||
const openedHandle = await open(
|
||||
descriptorStaging,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
let openedStaging;
|
||||
try {
|
||||
openedStaging = await openedHandle.stat();
|
||||
if (!openedStaging.isDirectory()) {
|
||||
throw new Error("promotion staging descriptor is not a directory");
|
||||
}
|
||||
if (
|
||||
openedStaging.dev !== createdStagingIdentity.dev ||
|
||||
openedStaging.ino !== createdStagingIdentity.ino
|
||||
) {
|
||||
throw new Error("promotion staging leaf identity changed between mkdir and open");
|
||||
}
|
||||
openedIdentityVerified = true;
|
||||
} catch (error) {
|
||||
try {
|
||||
await openedHandle.close();
|
||||
} catch (closeError) {
|
||||
throw new AggregateError(
|
||||
[error, closeError],
|
||||
error instanceof Error
|
||||
? `${error.message}; rejected staging descriptor close also failed`
|
||||
: "rejected staging descriptor and close both failed",
|
||||
{ cause: closeError },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
stagingHandle = openedHandle;
|
||||
await stagingHandle.chmod(0o700);
|
||||
stagingIdentity = Object.freeze({ dev: openedStaging.dev, ino: openedStaging.ino });
|
||||
const stagingDescriptorRoot = `/proc/self/fd/${stagingHandle.fd}`;
|
||||
assertStagingIdentity(await stat(stagingDescriptorRoot), stagingIdentity);
|
||||
for (const file of files) {
|
||||
const handle = await open(
|
||||
path.join(descriptorStaging, file.name),
|
||||
path.join(stagingDescriptorRoot, file.name),
|
||||
constants.O_WRONLY |
|
||||
constants.O_CREAT |
|
||||
constants.O_EXCL |
|
||||
@@ -313,15 +513,20 @@ async function publishPrivateStaging(
|
||||
0o400,
|
||||
);
|
||||
try {
|
||||
await handle.chmod(0o400);
|
||||
await handle.writeFile(file.bytes);
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await afterFileWrite?.(file.name);
|
||||
}
|
||||
await syncDirectory(descriptorStaging);
|
||||
await syncHandle(stagingHandle);
|
||||
await syncHandle(parentHandle);
|
||||
await afterStagingWrite?.();
|
||||
await beforeSeal?.();
|
||||
const capturedFiles = await captureStagedFiles(stagingHandle, files);
|
||||
await sealStagedFiles?.(capturedFiles);
|
||||
const after = await lstat(parentPath);
|
||||
if (
|
||||
after.dev !== before.dev ||
|
||||
@@ -335,20 +540,102 @@ async function publishPrivateStaging(
|
||||
if (!visible.isDirectory() || visible.isSymbolicLink()) {
|
||||
throw new Error("promotion staging visibility identity mismatch");
|
||||
}
|
||||
assertStagingIdentity(visible, stagingIdentity);
|
||||
ownsStaging = false;
|
||||
return Object.freeze({
|
||||
finalizedPromotion = Object.freeze({
|
||||
stagingRoot: visibleStaging,
|
||||
cleanupToken,
|
||||
runnerTempIdentity: Object.freeze({ dev: before.dev, ino: before.ino }),
|
||||
stagingIdentity,
|
||||
files: Object.freeze(
|
||||
files.map(({ name, sha256: digest }) => Object.freeze({ name, sha256: digest })),
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
if (ownsStaging) {
|
||||
await rm(descriptorStaging, { recursive: true, force: true }).catch(() => undefined);
|
||||
} catch (error) {
|
||||
await cleanup(error);
|
||||
throw error;
|
||||
}
|
||||
await cleanup();
|
||||
return finalizedPromotion;
|
||||
}
|
||||
|
||||
async function captureStagedFiles(
|
||||
stagingHandle: Awaited<ReturnType<typeof open>>,
|
||||
declaredFiles: readonly StagedFile[],
|
||||
): Promise<Readonly<Record<PromotedFileName, Buffer>>> {
|
||||
const descriptorRoot = `/proc/self/fd/${stagingHandle.fd}`;
|
||||
const names = (await readdir(descriptorRoot)).sort(asciiCompare);
|
||||
if (
|
||||
JSON.stringify(names) !==
|
||||
JSON.stringify([...PROMOTED_FILE_NAMES].sort(asciiCompare))
|
||||
) {
|
||||
throw new Error("staged promotion seal requires exactly the canonical five files");
|
||||
}
|
||||
const declared = new Map(declaredFiles.map((file) => [file.name, file] as const));
|
||||
const captured = {} as Record<PromotedFileName, Buffer>;
|
||||
for (const name of PROMOTED_FILE_NAMES) {
|
||||
const expected = declared.get(name)!;
|
||||
const handle = await open(
|
||||
path.join(descriptorRoot, name),
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW,
|
||||
);
|
||||
try {
|
||||
const before = await handle.stat();
|
||||
const maxBytes = name === "release-candidate.tar.gz" ? 268_435_456 : 16_777_216;
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.nlink !== 1 ||
|
||||
(before.mode & 0o777) !== 0o400 ||
|
||||
before.size <= 0 ||
|
||||
before.size > maxBytes
|
||||
) {
|
||||
throw new Error(
|
||||
`staged promotion file must be regular, single-link, bounded, and mode 0400: ${name}`,
|
||||
);
|
||||
}
|
||||
const bytes = await handle.readFile();
|
||||
const after = await handle.stat();
|
||||
if (
|
||||
after.dev !== before.dev ||
|
||||
after.ino !== before.ino ||
|
||||
after.size !== before.size ||
|
||||
after.nlink !== 1 ||
|
||||
(after.mode & 0o777) !== 0o400 ||
|
||||
bytes.byteLength !== before.size
|
||||
) {
|
||||
throw new Error(`staged promotion file inode or size changed during seal: ${name}`);
|
||||
}
|
||||
if (sha256(bytes) !== expected.sha256) {
|
||||
throw new Error(`staged promotion file digest mismatch during seal: ${name}`);
|
||||
}
|
||||
captured[name] = bytes;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await parentHandle.close();
|
||||
}
|
||||
return Object.freeze(captured);
|
||||
}
|
||||
|
||||
function asciiCompare(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
function assertStagingIdentity(
|
||||
metadata: Readonly<{
|
||||
dev: number;
|
||||
ino: number;
|
||||
isDirectory: () => boolean;
|
||||
isSymbolicLink?: () => boolean;
|
||||
}>,
|
||||
expected: Readonly<{ dev: number; ino: number }>,
|
||||
): void {
|
||||
if (
|
||||
metadata.dev !== expected.dev ||
|
||||
metadata.ino !== expected.ino ||
|
||||
!metadata.isDirectory() ||
|
||||
metadata.isSymbolicLink?.()
|
||||
) {
|
||||
throw new Error("promotion staging leaf identity changed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,18 +696,6 @@ function sha256(bytes: Buffer): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
async function syncDirectory(directory: string): Promise<void> {
|
||||
const handle = await open(
|
||||
directory,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
try {
|
||||
await syncHandle(handle);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function syncHandle(handle: Awaited<ReturnType<typeof open>>): Promise<void> {
|
||||
try {
|
||||
await handle.sync();
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
import { createHash, createPublicKey } from "node:crypto";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
PROMOTION_VERIFIER_ID,
|
||||
PROMOTION_VERIFIER_VERSION,
|
||||
evaluatePromotionEvidence,
|
||||
providerPublicKeyFingerprint,
|
||||
trustPolicySha256,
|
||||
type ProviderVerificationArtifactType,
|
||||
type ProviderTrust,
|
||||
} from "./provider-evidence.ts";
|
||||
import {
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
releaseCandidateManifestSchema,
|
||||
verifyReleaseCandidate,
|
||||
} from "./release-candidate.ts";
|
||||
import { verifyArchivedLocalEvidence } from "./local-release-evidence.ts";
|
||||
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
||||
import { supplyChainDigest } from "./supply-chain.ts";
|
||||
|
||||
type LocalEvidenceVerifier = typeof verifyArchivedLocalEvidence;
|
||||
|
||||
export type VerifyPromotionInputsOptions = Readonly<{
|
||||
artifactType: ProviderVerificationArtifactType;
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
repositoryRoot?: string;
|
||||
providerEvidenceRoot?: string;
|
||||
trustRoot?: string;
|
||||
verifyLocalEvidence?: LocalEvidenceVerifier;
|
||||
nowEpochMs?: () => number;
|
||||
}>;
|
||||
|
||||
export async function verifyPromotionInputs(
|
||||
options: VerifyPromotionInputsOptions,
|
||||
) {
|
||||
const environment = options.environment ?? process.env;
|
||||
const repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd());
|
||||
const trustRoot = path.resolve(options.trustRoot ?? repositoryRoot);
|
||||
const providerEvidenceRoot = path.resolve(
|
||||
options.providerEvidenceRoot ?? repositoryRoot,
|
||||
);
|
||||
const inputFailures: string[] = [];
|
||||
const archive = await captureOptionalInput(
|
||||
providerEvidenceRoot,
|
||||
environment.CANDIDATE_ARCHIVE_PATH,
|
||||
268_435_456,
|
||||
"candidate archive",
|
||||
inputFailures,
|
||||
);
|
||||
if (!environment.CANDIDATE_ARCHIVE_SHA256) {
|
||||
inputFailures.push("candidate archive expected SHA-256 is missing");
|
||||
} else if (
|
||||
archive.sha256 &&
|
||||
archive.sha256 !== environment.CANDIDATE_ARCHIVE_SHA256
|
||||
) {
|
||||
inputFailures.push("candidate archive SHA-256 does not match immutable output");
|
||||
}
|
||||
const vulnerabilityCapture = await captureOptionalInput(
|
||||
providerEvidenceRoot,
|
||||
environment.VULNERABILITY_REPORT_PATH,
|
||||
16_777_216,
|
||||
"vulnerability report",
|
||||
inputFailures,
|
||||
);
|
||||
const provenanceCapture = await captureOptionalInput(
|
||||
providerEvidenceRoot,
|
||||
environment.PROVENANCE_ATTESTATION_PATH,
|
||||
16_777_216,
|
||||
"provenance attestation",
|
||||
inputFailures,
|
||||
);
|
||||
const manifestDocument = await requiredJson(
|
||||
repositoryRoot,
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
);
|
||||
const manifest = releaseCandidateManifestSchema.parse(manifestDocument);
|
||||
const candidate = await verifyReleaseCandidate(
|
||||
manifestDocument,
|
||||
repositoryRoot,
|
||||
);
|
||||
const localEvidence = await (
|
||||
options.verifyLocalEvidence ?? verifyArchivedLocalEvidence
|
||||
)({ extractionRoot: repositoryRoot, expectedManifest: manifest });
|
||||
const vulnerabilityReport = parseCapturedJson(vulnerabilityCapture.bytes);
|
||||
const provenanceAttestation = parseCapturedJson(provenanceCapture.bytes);
|
||||
const vulnerabilityTrust = await readProviderTrust(
|
||||
trustRoot,
|
||||
environment.VULNERABILITY_PUBLIC_KEY_PATH,
|
||||
environment.VULNERABILITY_KEY_ID,
|
||||
);
|
||||
const provenanceTrust = await readProviderTrust(
|
||||
trustRoot,
|
||||
environment.PROVENANCE_PUBLIC_KEY_PATH,
|
||||
environment.PROVENANCE_KEY_ID,
|
||||
);
|
||||
const runId = environment.CI_RUN_ID ?? "missing-run";
|
||||
const runAttempt = Number(environment.CI_RUN_ATTEMPT);
|
||||
if (!environment.CI_RUN_ID) inputFailures.push("provider expected run ID is missing");
|
||||
if (!Number.isInteger(runAttempt) || runAttempt < 1 || runAttempt > 1_000) {
|
||||
inputFailures.push("provider expected run attempt is missing or invalid");
|
||||
}
|
||||
if (!localEvidence.identity) {
|
||||
inputFailures.push("archived local evidence identity is unavailable");
|
||||
}
|
||||
if (
|
||||
environment.EXPECTED_SOURCE_REVISION &&
|
||||
localEvidence.identity &&
|
||||
environment.EXPECTED_SOURCE_REVISION !== localEvidence.identity.sourceRevision
|
||||
) {
|
||||
inputFailures.push(
|
||||
`provider expected source revision mismatch: expected ${environment.EXPECTED_SOURCE_REVISION}, archived ${localEvidence.identity.sourceRevision}`,
|
||||
);
|
||||
}
|
||||
const vulnerabilityInvocationNonce = requiredExpectedNonce(
|
||||
environment.VULNERABILITY_INVOCATION_NONCE,
|
||||
"vulnerability",
|
||||
inputFailures,
|
||||
);
|
||||
const provenanceInvocationNonce = requiredExpectedNonce(
|
||||
environment.PROVENANCE_INVOCATION_NONCE,
|
||||
"provenance",
|
||||
inputFailures,
|
||||
);
|
||||
const expected = {
|
||||
run: { id: runId, attempt: Number.isInteger(runAttempt) ? runAttempt : 1 },
|
||||
source: {
|
||||
revision:
|
||||
localEvidence.identity?.sourceRevision ??
|
||||
environment.EXPECTED_SOURCE_REVISION ??
|
||||
"0".repeat(40),
|
||||
sourceSetSha256: localEvidence.identity?.sourceSetSha256 ?? "0".repeat(64),
|
||||
},
|
||||
candidate: {
|
||||
archiveSha256: archive.sha256 ?? "0".repeat(64),
|
||||
bundleSha256: manifest.bundleSha256,
|
||||
distSha256: manifest.distSha256,
|
||||
lockfileSha256: manifest.lockfileSha256,
|
||||
},
|
||||
vulnerabilityInvocationNonce,
|
||||
provenanceInvocationNonce,
|
||||
} as const;
|
||||
const result = evaluatePromotionEvidence({
|
||||
expected,
|
||||
localStatus: localEvidence.status,
|
||||
vulnerabilityReport,
|
||||
provenanceAttestation,
|
||||
vulnerabilityTrust,
|
||||
provenanceTrust,
|
||||
nowEpochMs: options.nowEpochMs,
|
||||
});
|
||||
const failures = [
|
||||
...inputFailures,
|
||||
...candidate.failures,
|
||||
...localEvidence.failures,
|
||||
...result.failures,
|
||||
];
|
||||
const now = (options.nowEpochMs ?? Date.now)();
|
||||
const common = {
|
||||
schemaVersion: 3 as const,
|
||||
artifactType: options.artifactType,
|
||||
verifiedAt: new Date(now).toISOString(),
|
||||
status:
|
||||
failures.length === 0 && result.status === "PASS"
|
||||
? ("PASS" as const)
|
||||
: ("FAIL_UNVERIFIED" as const),
|
||||
verifier: Object.freeze({
|
||||
id: PROMOTION_VERIFIER_ID,
|
||||
version: PROMOTION_VERIFIER_VERSION,
|
||||
}),
|
||||
run: expected.run,
|
||||
source: expected.source,
|
||||
candidate: expected.candidate,
|
||||
providerEvidence: Object.freeze({
|
||||
vulnerabilityReportSha256: vulnerabilityCapture.sha256 ?? "0".repeat(64),
|
||||
provenanceAttestationSha256: provenanceCapture.sha256 ?? "0".repeat(64),
|
||||
vulnerabilityInvocationNonce: expected.vulnerabilityInvocationNonce,
|
||||
provenanceInvocationNonce: expected.provenanceInvocationNonce,
|
||||
vulnerabilityKeyId:
|
||||
vulnerabilityTrust?.keyId ?? environment.VULNERABILITY_KEY_ID ?? "missing-key",
|
||||
vulnerabilityKeyFingerprint:
|
||||
vulnerabilityTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`,
|
||||
provenanceKeyId:
|
||||
provenanceTrust?.keyId ?? environment.PROVENANCE_KEY_ID ?? "missing-key",
|
||||
provenanceKeyFingerprint:
|
||||
provenanceTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`,
|
||||
}),
|
||||
trustPolicySha256: verificationTrustPolicySha256(
|
||||
vulnerabilityTrust,
|
||||
provenanceTrust,
|
||||
environment,
|
||||
),
|
||||
failures: Object.freeze(failures),
|
||||
};
|
||||
return options.artifactType === "provider-verification"
|
||||
? Object.freeze({
|
||||
...common,
|
||||
artifactType: "provider-verification" as const,
|
||||
vulnerabilityStatus: result.vulnerabilityStatus,
|
||||
provenanceAttestationStatus: result.provenanceAttestationStatus,
|
||||
})
|
||||
: Object.freeze({
|
||||
...common,
|
||||
artifactType: "promotion-verification" as const,
|
||||
localEvidenceStatus: localEvidence.status,
|
||||
localEvidenceAssessmentSha256:
|
||||
localEvidence.identity?.assessmentSha256 ?? "0".repeat(64),
|
||||
providerVerificationSha256:
|
||||
environment.PROVIDER_VERIFICATION_SHA256 ?? "0".repeat(64),
|
||||
});
|
||||
}
|
||||
|
||||
function requiredExpectedNonce(
|
||||
value: string | undefined,
|
||||
label: "vulnerability" | "provenance",
|
||||
failures: string[],
|
||||
): string {
|
||||
if (value && /^[a-f0-9]{64}$/u.test(value)) return value;
|
||||
failures.push(`${label} expected invocation nonce is missing or invalid`);
|
||||
return "0".repeat(64);
|
||||
}
|
||||
|
||||
function verificationTrustPolicySha256(
|
||||
vulnerabilityTrust: ProviderTrust | null,
|
||||
provenanceTrust: ProviderTrust | null,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
): string {
|
||||
if (vulnerabilityTrust && provenanceTrust) {
|
||||
return trustPolicySha256({ vulnerabilityTrust, provenanceTrust });
|
||||
}
|
||||
return supplyChainDigest({
|
||||
algorithm: "Ed25519",
|
||||
vulnerability: {
|
||||
keyId: vulnerabilityTrust?.keyId ?? environment.VULNERABILITY_KEY_ID ?? "missing-key",
|
||||
publicKeyFingerprint:
|
||||
vulnerabilityTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`,
|
||||
},
|
||||
provenance: {
|
||||
keyId: provenanceTrust?.keyId ?? environment.PROVENANCE_KEY_ID ?? "missing-key",
|
||||
publicKeyFingerprint:
|
||||
provenanceTrust?.publicKeyFingerprint ?? `sha256:${"0".repeat(64)}`,
|
||||
},
|
||||
issuedAtFutureSkewMs: 5 * 60 * 1_000,
|
||||
maximumLifetimeMs: 2 * 60 * 60 * 1_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function readProviderTrust(
|
||||
repositoryRoot: string,
|
||||
publicKeyPath: string | undefined,
|
||||
keyId: string | undefined,
|
||||
): Promise<ProviderTrust | null> {
|
||||
if (!publicKeyPath || !keyId?.trim()) return null;
|
||||
try {
|
||||
const publicKey = createPublicKey(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
await boundedConfiguredFile(repositoryRoot, publicKeyPath, 1_048_576),
|
||||
),
|
||||
);
|
||||
return Object.freeze({
|
||||
keyId,
|
||||
publicKey,
|
||||
publicKeyFingerprint: providerPublicKeyFingerprint(publicKey),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function captureOptionalInput(
|
||||
root: string,
|
||||
configuredPath: string | undefined,
|
||||
maxBytes: number,
|
||||
label: string,
|
||||
failures: string[],
|
||||
): Promise<Readonly<{ bytes: Buffer | null; sha256: string | null }>> {
|
||||
if (!configuredPath) {
|
||||
failures.push(`${label} path is missing`);
|
||||
return Object.freeze({ bytes: null, sha256: null });
|
||||
}
|
||||
try {
|
||||
const bytes = await boundedConfiguredFile(root, configuredPath, maxBytes);
|
||||
return Object.freeze({
|
||||
bytes,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
});
|
||||
} catch (error) {
|
||||
failures.push(
|
||||
`${label} capture failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return Object.freeze({ bytes: null, sha256: null });
|
||||
}
|
||||
}
|
||||
|
||||
function parseCapturedJson(bytes: Buffer | null): unknown {
|
||||
if (!bytes) return null;
|
||||
try {
|
||||
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function requiredJson(
|
||||
repositoryRoot: string,
|
||||
file: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const value: unknown = JSON.parse(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
await boundedConfiguredFile(repositoryRoot, file, 8_388_608),
|
||||
),
|
||||
);
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(`${file} must be a JSON object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function boundedConfiguredFile(
|
||||
configuredRoot: string,
|
||||
configuredPath: string,
|
||||
maxBytes: number,
|
||||
): Promise<Buffer> {
|
||||
const root = path.resolve(configuredRoot);
|
||||
const absolute = path.resolve(root, configuredPath);
|
||||
const relative = path.relative(root, absolute);
|
||||
const outside = relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
|
||||
return readBoundedRegularFile({
|
||||
root: outside ? path.dirname(absolute) : root,
|
||||
relativePath: outside ? path.basename(absolute) : relative.replaceAll(path.sep, "/"),
|
||||
maxBytes,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
export type ProviderKind = "vulnerability" | "provenance";
|
||||
|
||||
const MEMORY_MAX = 1_073_741_824;
|
||||
const TASKS_MAX = 64;
|
||||
const STOP_TIMEOUT_MS = 5_000;
|
||||
const RUNTIME_GRACE_MS = 10_000;
|
||||
const UNIT_NAME = /^ca-provider-(?:vulnerability|provenance)-[1-9][0-9]*-[0-9a-f]{24}\.scope$/u;
|
||||
const UNIT_NONCE = /^[0-9a-f]{24}$/u;
|
||||
const ENVIRONMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
||||
const ENFORCEMENT_GATE = [
|
||||
'cgroup_path=""',
|
||||
"while IFS=: read -r hierarchy controllers candidate; do",
|
||||
' if [ "$hierarchy" = 0 ] && [ -z "$controllers" ]; then cgroup_path=$candidate; fi',
|
||||
"done < /proc/self/cgroup",
|
||||
'if [ -z "$cgroup_path" ]; then',
|
||||
" printf '%s\\n' 'provider cgroup enforcement failed: unified cgroup v2 membership is required' >&2",
|
||||
" exit 125",
|
||||
"fi",
|
||||
'case "$cgroup_path" in',
|
||||
' */"$0") ;;',
|
||||
" *)",
|
||||
" printf '%s\\n' 'provider cgroup enforcement failed: unit membership is invalid' >&2",
|
||||
" exit 125",
|
||||
" ;;",
|
||||
"esac",
|
||||
"cgroup_root=/sys/fs/cgroup$cgroup_path",
|
||||
"require_cgroup_value() {",
|
||||
' actual=$(/bin/cat "$cgroup_root/$1") || {',
|
||||
" printf 'provider cgroup enforcement failed: cannot read %s\\n' \"$1\" >&2",
|
||||
" exit 125",
|
||||
" }",
|
||||
' if [ "$actual" != "$2" ]; then',
|
||||
" printf 'provider cgroup enforcement failed: %s is %s, expected %s\\n' \"$1\" \"$actual\" \"$2\" >&2",
|
||||
" exit 125",
|
||||
" fi",
|
||||
"}",
|
||||
`require_cgroup_value memory.max ${MEMORY_MAX}`,
|
||||
"require_cgroup_value memory.swap.max 0",
|
||||
`require_cgroup_value pids.max ${TASKS_MAX}`,
|
||||
"require_cgroup_value cpu.max '100000 100000'",
|
||||
'exec "$@"',
|
||||
].join("\n");
|
||||
|
||||
export function formatProviderCgroupUnitName(
|
||||
kind: ProviderKind,
|
||||
supervisorPid: number,
|
||||
nonce: string,
|
||||
): string {
|
||||
if (!Number.isSafeInteger(supervisorPid) || supervisorPid <= 0 || !UNIT_NONCE.test(nonce)) {
|
||||
throw new TypeError("provider cgroup unit identity is invalid");
|
||||
}
|
||||
const unit = `ca-provider-${kind}-${supervisorPid}-${nonce}.scope`;
|
||||
assertUnit(unit);
|
||||
return unit;
|
||||
}
|
||||
|
||||
export function systemdRunProviderArguments(
|
||||
unit: string,
|
||||
timeoutMs: number,
|
||||
cpuSeconds: number,
|
||||
nodeExecutable: string,
|
||||
wrapperScript: string,
|
||||
reportPath: string,
|
||||
reportDev: number,
|
||||
reportIno: number,
|
||||
): string[] {
|
||||
assertUnit(unit);
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > Number.MAX_SAFE_INTEGER - RUNTIME_GRACE_MS) {
|
||||
throw new TypeError("provider cgroup runtime is invalid");
|
||||
}
|
||||
if (!Number.isSafeInteger(cpuSeconds) || cpuSeconds <= 0) {
|
||||
throw new TypeError("provider cgroup CPU limit is invalid");
|
||||
}
|
||||
if (
|
||||
!nodeExecutable.startsWith("/") || !wrapperScript.startsWith("/") ||
|
||||
!reportPath.startsWith("/") || reportPath.includes("\0") ||
|
||||
!Number.isSafeInteger(reportDev) || reportDev <= 0 ||
|
||||
!Number.isSafeInteger(reportIno) || reportIno <= 0
|
||||
) {
|
||||
throw new TypeError("provider scope wrapper path is invalid");
|
||||
}
|
||||
return [
|
||||
"--user",
|
||||
"--scope",
|
||||
"--collect",
|
||||
"--quiet",
|
||||
"--expand-environment=no",
|
||||
`--unit=${unit}`,
|
||||
`--property=MemoryMax=${MEMORY_MAX}`,
|
||||
"--property=MemorySwapMax=0",
|
||||
`--property=TasksMax=${TASKS_MAX}`,
|
||||
"--property=CPUQuota=100%",
|
||||
"--property=CPUQuotaPeriodSec=100ms",
|
||||
"--property=KillMode=control-group",
|
||||
"--property=SendSIGKILL=yes",
|
||||
`--property=TimeoutStopSec=${STOP_TIMEOUT_MS}ms`,
|
||||
`--property=RuntimeMaxSec=${timeoutMs + RUNTIME_GRACE_MS}ms`,
|
||||
"--",
|
||||
"/bin/sh",
|
||||
"-eu",
|
||||
"-c",
|
||||
ENFORCEMENT_GATE,
|
||||
unit,
|
||||
nodeExecutable,
|
||||
wrapperScript,
|
||||
String(cpuSeconds),
|
||||
reportPath,
|
||||
String(reportDev),
|
||||
String(reportIno),
|
||||
];
|
||||
}
|
||||
|
||||
export type ProviderScopeFrame = Readonly<{
|
||||
bwrapInput: Buffer;
|
||||
reportPath: string;
|
||||
reportDev: number;
|
||||
reportIno: number;
|
||||
}>;
|
||||
|
||||
export function encodeProviderScopeFrame(input: ProviderScopeFrame): Buffer {
|
||||
if (
|
||||
!Buffer.isBuffer(input.bwrapInput) || input.bwrapInput.byteLength === 0 ||
|
||||
!input.reportPath.startsWith("/") || input.reportPath.includes("\0") ||
|
||||
!Number.isSafeInteger(input.reportDev) || input.reportDev <= 0 ||
|
||||
!Number.isSafeInteger(input.reportIno) || input.reportIno <= 0
|
||||
) {
|
||||
throw new TypeError("provider scope frame is invalid");
|
||||
}
|
||||
const payload = Buffer.from(JSON.stringify({
|
||||
bwrapInputBase64: input.bwrapInput.toString("base64"),
|
||||
reportPath: input.reportPath,
|
||||
reportDev: input.reportDev,
|
||||
reportIno: input.reportIno,
|
||||
}));
|
||||
const frame = Buffer.allocUnsafe(4 + payload.byteLength);
|
||||
frame.writeUInt32BE(payload.byteLength, 0);
|
||||
payload.copy(frame, 4);
|
||||
return frame;
|
||||
}
|
||||
|
||||
export function encodeProviderBwrapInput(
|
||||
arguments_: readonly string[],
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): Buffer {
|
||||
if (arguments_.some((argument) => argument.includes("\0"))) {
|
||||
throw new TypeError("provider bwrap argument is invalid");
|
||||
}
|
||||
const entries = Object.entries(environment).sort(([left], [right]) =>
|
||||
left < right ? -1 : left > right ? 1 : 0,
|
||||
);
|
||||
if (entries.some(([name, value]) =>
|
||||
!ENVIRONMENT_NAME.test(name) || value === undefined || value.includes("\0")
|
||||
)) {
|
||||
throw new TypeError("provider bwrap environment is invalid");
|
||||
}
|
||||
const input = ["--clearenv"];
|
||||
for (const [name, value] of entries) input.push("--setenv", name, value ?? "");
|
||||
input.push(...arguments_);
|
||||
return Buffer.from(`${input.join("\0")}\0`);
|
||||
}
|
||||
|
||||
export function systemctlKillProviderArguments(unit: string): string[] {
|
||||
assertUnit(unit);
|
||||
return ["--user", "kill", "--kill-whom=all", "--signal=SIGKILL", unit];
|
||||
}
|
||||
|
||||
function assertUnit(unit: string): void {
|
||||
if (!UNIT_NAME.test(unit)) throw new TypeError("provider cgroup unit name is invalid");
|
||||
}
|
||||
@@ -37,6 +37,16 @@ const candidateSchema = z
|
||||
})
|
||||
.strict();
|
||||
const providerRunSchema = runSchema.extend({ invocationNonce: nonce }).strict();
|
||||
export const secretScanAttestationSchema = z
|
||||
.object({
|
||||
status: z.literal("PASS"),
|
||||
localEvidenceAssessmentSha256: sha256,
|
||||
sourceSetSha256: sha256,
|
||||
policySha256: sha256,
|
||||
sarifSha256: sha256,
|
||||
scanInputSha256: sha256,
|
||||
})
|
||||
.strict();
|
||||
const signatureSchema = z
|
||||
.object({
|
||||
algorithm: z.literal("Ed25519"),
|
||||
@@ -60,6 +70,7 @@ export const vulnerabilityProviderReportSchema = z
|
||||
.object({
|
||||
...providerCommon,
|
||||
evidenceType: z.literal("vulnerability-report"),
|
||||
secretScanAttestation: secretScanAttestationSchema,
|
||||
findings: z.array(z.record(z.string(), z.json())),
|
||||
})
|
||||
.strict();
|
||||
@@ -95,6 +106,7 @@ const verificationCommon = {
|
||||
vulnerabilityKeyFingerprint: fingerprint,
|
||||
provenanceKeyId: nonEmptyString,
|
||||
provenanceKeyFingerprint: fingerprint,
|
||||
secretScanAttestation: secretScanAttestationSchema,
|
||||
})
|
||||
.strict(),
|
||||
trustPolicySha256: sha256,
|
||||
@@ -169,6 +181,7 @@ export type ExpectedPromotionContext = Readonly<{
|
||||
}>;
|
||||
vulnerabilityInvocationNonce: string;
|
||||
provenanceInvocationNonce: string;
|
||||
secretScanAttestation: z.infer<typeof secretScanAttestationSchema>;
|
||||
}>;
|
||||
|
||||
export type PromotionEvidenceResult = Readonly<{
|
||||
@@ -211,6 +224,12 @@ export function validateProviderEvidence(input: Readonly<{
|
||||
now,
|
||||
failures,
|
||||
);
|
||||
if (
|
||||
JSON.stringify(parsed.data.secretScanAttestation) !==
|
||||
JSON.stringify(input.expected.secretScanAttestation)
|
||||
) {
|
||||
failures.push("vulnerability report secret scan attestation mismatch");
|
||||
}
|
||||
if (parsed.data.findings.length > 0) {
|
||||
failures.push("vulnerability report contains findings");
|
||||
}
|
||||
@@ -268,6 +287,7 @@ export function createTrustPolicy(input: Readonly<{
|
||||
vulnerabilityTrust: ProviderTrust;
|
||||
provenanceTrust: ProviderTrust;
|
||||
}>) {
|
||||
assertDistinctProviderTrust(input);
|
||||
return Object.freeze({
|
||||
algorithm: "Ed25519" as const,
|
||||
vulnerability: Object.freeze({
|
||||
@@ -283,6 +303,26 @@ export function createTrustPolicy(input: Readonly<{
|
||||
});
|
||||
}
|
||||
|
||||
export function assertDistinctProviderTrust(input: Readonly<{
|
||||
vulnerabilityTrust: ProviderTrust;
|
||||
provenanceTrust: ProviderTrust;
|
||||
}>): void {
|
||||
const vulnerabilityFingerprint = providerPublicKeyFingerprint(
|
||||
input.vulnerabilityTrust.publicKey,
|
||||
);
|
||||
const provenanceFingerprint = providerPublicKeyFingerprint(
|
||||
input.provenanceTrust.publicKey,
|
||||
);
|
||||
if (
|
||||
input.vulnerabilityTrust.keyId === input.provenanceTrust.keyId ||
|
||||
vulnerabilityFingerprint === provenanceFingerprint ||
|
||||
input.vulnerabilityTrust.publicKeyFingerprint ===
|
||||
input.provenanceTrust.publicKeyFingerprint
|
||||
) {
|
||||
throw new TypeError("provider trust roles require distinct key identities and DER-SPKI fingerprints");
|
||||
}
|
||||
}
|
||||
|
||||
export function trustPolicySha256(input: Readonly<{
|
||||
vulnerabilityTrust: ProviderTrust;
|
||||
provenanceTrust: ProviderTrust;
|
||||
@@ -323,6 +363,12 @@ export function evaluatePromotionEvidence(input: Readonly<{
|
||||
now,
|
||||
failures,
|
||||
);
|
||||
if (
|
||||
JSON.stringify(vulnerability.data.secretScanAttestation) !==
|
||||
JSON.stringify(input.expected.secretScanAttestation)
|
||||
) {
|
||||
failures.push("vulnerability report secret scan attestation mismatch");
|
||||
}
|
||||
if (vulnerability.data.findings.length > 0) {
|
||||
failures.push("vulnerability report contains findings");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,763 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, open, type FileHandle } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
decodeProviderGuardianPublished,
|
||||
decodeProviderGuardianReady,
|
||||
encodeProviderGuardianCommit,
|
||||
encodeProviderGuardianGuard,
|
||||
encodeProviderGuardianPublish,
|
||||
MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES,
|
||||
MAX_PROVIDER_GUARDIAN_LEASE_MS,
|
||||
MAX_PROVIDER_SEALED_BYTES,
|
||||
providerGuardianRawStagingLeaf,
|
||||
providerGuardianSealedTempLeaf,
|
||||
type ProviderGuardianKind,
|
||||
} from "./provider-guardian-protocol.ts";
|
||||
import { cleanupOwnedProviderReport } from "./provider-raw-cleanup.ts";
|
||||
|
||||
const RESPONSE_TIMEOUT_MS = 5_000;
|
||||
const CLOSE_TIMEOUT_MS = 5_000;
|
||||
const MAX_CONTROL_OUTPUT_BYTES = 4_096;
|
||||
|
||||
type OwnedIdentity = Readonly<{ dev: number; ino: number }>;
|
||||
type RecoveryAuthority = Readonly<{
|
||||
rawDirectoryHandle: FileHandle;
|
||||
evidenceDirectoryHandle: FileHandle;
|
||||
rawStagingHandle: FileHandle;
|
||||
sealedTempHandle: FileHandle;
|
||||
rawIdentity: OwnedIdentity;
|
||||
sealedIdentity: OwnedIdentity;
|
||||
rawStagingPinnedPath: string;
|
||||
rawPinnedPath: string;
|
||||
sealedTempPinnedPath: string;
|
||||
sealedPinnedPath: string;
|
||||
}>;
|
||||
|
||||
export type ProviderGuardianLease = Readonly<{
|
||||
pid: number;
|
||||
rawPath: string;
|
||||
rawIdentity: OwnedIdentity;
|
||||
sealedPath: string;
|
||||
sealedTempPath: string;
|
||||
sealedIdentity: OwnedIdentity;
|
||||
prematureExit: Promise<Error>;
|
||||
publish(bytes: Buffer): Promise<void>;
|
||||
commit(): Promise<void>;
|
||||
abort(): Promise<void>;
|
||||
}>;
|
||||
|
||||
export type StartProviderGuardianInput = Readonly<{
|
||||
kind: ProviderGuardianKind;
|
||||
workspaceRoot: string;
|
||||
leaseMs: number;
|
||||
guardianScript: string;
|
||||
}>;
|
||||
|
||||
export type ProviderScopeGuardianLatch = Readonly<{
|
||||
activeFailure: Promise<Error>;
|
||||
close(): Promise<void>;
|
||||
failure(): Error | undefined;
|
||||
}>;
|
||||
|
||||
export function createProviderScopeGuardianLatch(
|
||||
guardianExit: Promise<Error>,
|
||||
): ProviderScopeGuardianLatch {
|
||||
let active = true;
|
||||
let closing: Promise<void> | undefined;
|
||||
let observedFailure: Error | undefined;
|
||||
let signalActiveFailure!: (error: Error) => void;
|
||||
const activeFailure = new Promise<Error>((resolve) => { signalActiveFailure = resolve; });
|
||||
void guardianExit.then((error) => {
|
||||
observedFailure = error;
|
||||
if (active) signalActiveFailure(error);
|
||||
});
|
||||
return Object.freeze({
|
||||
activeFailure,
|
||||
close: () => {
|
||||
closing ??= Promise.resolve().then(() => { active = false; });
|
||||
return closing;
|
||||
},
|
||||
failure: () => observedFailure,
|
||||
});
|
||||
}
|
||||
|
||||
export function assertProviderGuardianLeasePaths(
|
||||
lease: Readonly<{ rawPath: string; sealedPath: string }>,
|
||||
expected: Readonly<{ rawPath: string; sealedPath: string }>,
|
||||
): void {
|
||||
if (lease.rawPath !== expected.rawPath) {
|
||||
throw new Error("provider guardian returned a noncanonical raw path");
|
||||
}
|
||||
if (lease.sealedPath !== expected.sealedPath) {
|
||||
throw new Error("provider guardian returned a noncanonical sealed path");
|
||||
}
|
||||
}
|
||||
|
||||
type GuardianResult = Readonly<{
|
||||
code: number | null;
|
||||
error?: Error;
|
||||
signal: NodeJS.Signals | null;
|
||||
}>;
|
||||
|
||||
export async function startProviderGuardian(
|
||||
input: StartProviderGuardianInput,
|
||||
): Promise<ProviderGuardianLease> {
|
||||
if (
|
||||
(input.kind !== "vulnerability" && input.kind !== "provenance") ||
|
||||
!path.isAbsolute(input.workspaceRoot) || !path.isAbsolute(input.guardianScript) ||
|
||||
!Number.isSafeInteger(input.leaseMs) || input.leaseMs <= 0 ||
|
||||
input.leaseMs > MAX_PROVIDER_GUARDIAN_LEASE_MS
|
||||
) {
|
||||
throw new TypeError("provider guardian client input is invalid");
|
||||
}
|
||||
const rawLeaf = input.kind === "vulnerability"
|
||||
? "vulnerability-report.json"
|
||||
: "provenance-attestation.json";
|
||||
const evidenceRoot = path.resolve(input.workspaceRoot, "provider-evidence");
|
||||
const rawDirectory = path.join(evidenceRoot, "untrusted");
|
||||
const rawPath = path.join(evidenceRoot, "untrusted", rawLeaf);
|
||||
const sealedPath = path.join(evidenceRoot, rawLeaf);
|
||||
const nonce = randomBytes(32);
|
||||
const rawStagingLeaf = providerGuardianRawStagingLeaf(input.kind, nonce);
|
||||
const sealedTempLeaf = providerGuardianSealedTempLeaf(input.kind, nonce);
|
||||
const sealedTempPath = path.join(evidenceRoot, sealedTempLeaf);
|
||||
const recovery = await openRecoveryAuthority({
|
||||
rawDirectory,
|
||||
evidenceRoot,
|
||||
rawLeaf,
|
||||
rawStagingLeaf,
|
||||
sealedLeaf: rawLeaf,
|
||||
sealedTempLeaf,
|
||||
});
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
await assertRecoveryLeavesMissing(recovery);
|
||||
child = spawn(process.execPath, [input.guardianScript], {
|
||||
cwd: input.workspaceRoot,
|
||||
env: {},
|
||||
stdio: [
|
||||
"pipe",
|
||||
"pipe",
|
||||
"pipe",
|
||||
recovery.rawDirectoryHandle.fd,
|
||||
recovery.evidenceDirectoryHandle.fd,
|
||||
recovery.rawStagingHandle.fd,
|
||||
recovery.sealedTempHandle.fd,
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
return await closeRecoveryAndThrow(recovery, error);
|
||||
}
|
||||
if (!child.pid || !child.stdin || !child.stdout || !child.stderr) {
|
||||
child.kill("SIGKILL");
|
||||
return await closeRecoveryAndThrow(
|
||||
recovery,
|
||||
new Error("provider guardian process pipes are unavailable"),
|
||||
);
|
||||
}
|
||||
|
||||
let state: "starting" | "guarding" | "publishing" | "published" |
|
||||
"committing" | "aborting" | "terminated" = "starting";
|
||||
let stderr = Buffer.alloc(0);
|
||||
let inputError: Error | undefined;
|
||||
child.stdin.once("error", (error) => { inputError = error; });
|
||||
child.stderr.on("data", (chunk: Buffer | string) => {
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
if (stderr.byteLength < MAX_CONTROL_OUTPUT_BYTES) {
|
||||
stderr = Buffer.concat([stderr, bytes.subarray(0, MAX_CONTROL_OUTPUT_BYTES - stderr.byteLength)]);
|
||||
}
|
||||
});
|
||||
const completion = guardianCompletion(child);
|
||||
let signalPrematureExit!: (error: Error) => void;
|
||||
const prematureExit = new Promise<Error>((resolve) => { signalPrematureExit = resolve; });
|
||||
void completion.then((result) => {
|
||||
if (state === "guarding" || state === "publishing" || state === "published") {
|
||||
signalPrematureExit(guardianCloseError(result, stderr));
|
||||
}
|
||||
});
|
||||
|
||||
let ready: ReturnType<typeof decodeProviderGuardianReady>;
|
||||
try {
|
||||
const readyResponse = waitForFrame(child.stdout, completion, "READY");
|
||||
child.stdin.write(encodeProviderGuardianGuard({
|
||||
kind: input.kind,
|
||||
nonce,
|
||||
deadlineEpochMs: Date.now() + input.leaseMs,
|
||||
}));
|
||||
ready = decodeProviderGuardianReady(await readyResponse, nonce);
|
||||
if (
|
||||
ready.sealedTempLeaf !== sealedTempLeaf ||
|
||||
ready.rawDev !== recovery.rawIdentity.dev ||
|
||||
ready.rawIno !== recovery.rawIdentity.ino ||
|
||||
ready.sealedDev !== recovery.sealedIdentity.dev ||
|
||||
ready.sealedIno !== recovery.sealedIdentity.ino
|
||||
) {
|
||||
throw new TypeError("provider guardian READY identity is invalid for its allocation");
|
||||
}
|
||||
await assertPinnedLeafIdentity(recovery.rawPinnedPath, {
|
||||
dev: ready.rawDev,
|
||||
ino: ready.rawIno,
|
||||
}, 0o600);
|
||||
await assertPinnedLeafIdentity(recovery.sealedTempPinnedPath, {
|
||||
dev: ready.sealedDev,
|
||||
ino: ready.sealedIno,
|
||||
}, 0o600);
|
||||
await assertPinnedLeafMissing(recovery.rawStagingPinnedPath);
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
throw guardianCloseError(await completion, stderr);
|
||||
}
|
||||
state = "guarding";
|
||||
} catch (error) {
|
||||
state = "aborting";
|
||||
child.stdin.end();
|
||||
const failures = [toError(error)];
|
||||
try {
|
||||
await waitForClose(completion, child);
|
||||
} catch (closeError) {
|
||||
failures.push(toError(closeError));
|
||||
}
|
||||
try {
|
||||
await cleanupStartupRecovery(recovery);
|
||||
} catch (cleanupError) {
|
||||
failures.push(toError(cleanupError));
|
||||
}
|
||||
try {
|
||||
await closeRecoveryAuthority(recovery);
|
||||
} catch (closeError) {
|
||||
failures.push(toError(closeError));
|
||||
}
|
||||
state = "terminated";
|
||||
if (failures.length > 1) {
|
||||
throw new AggregateError(failures, "provider guardian startup failed", { cause: error });
|
||||
}
|
||||
throw failures[0]!;
|
||||
}
|
||||
|
||||
const rawIdentity = Object.freeze({ dev: ready.rawDev, ino: ready.rawIno });
|
||||
const sealedIdentity = Object.freeze({ dev: ready.sealedDev, ino: ready.sealedIno });
|
||||
const fallback = Object.freeze({
|
||||
rawStagingPath: recovery.rawStagingPinnedPath,
|
||||
rawPath: recovery.rawPinnedPath,
|
||||
rawIdentity,
|
||||
sealedPath: recovery.sealedPinnedPath,
|
||||
sealedTempPath: recovery.sealedTempPinnedPath,
|
||||
sealedIdentity,
|
||||
});
|
||||
|
||||
const publish = async (bytes: Buffer): Promise<void> => {
|
||||
if (state !== "guarding") throw new Error("provider guardian lease is not ready to publish");
|
||||
if (!Buffer.isBuffer(bytes) || bytes.byteLength <= 0 || bytes.byteLength > MAX_PROVIDER_SEALED_BYTES) {
|
||||
throw new TypeError("provider guardian sealed bytes are invalid");
|
||||
}
|
||||
state = "publishing";
|
||||
try {
|
||||
await writePinnedSealedBytes(recovery.sealedTempHandle, sealedIdentity, bytes);
|
||||
const publishedResponse = waitForFrame(child.stdout!, completion, "PUBLISHED");
|
||||
child.stdin!.write(encodeProviderGuardianPublish({
|
||||
nonce,
|
||||
sealedDev: sealedIdentity.dev,
|
||||
sealedIno: sealedIdentity.ino,
|
||||
size: bytes.byteLength,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
}));
|
||||
decodeProviderGuardianPublished(
|
||||
await publishedResponse,
|
||||
nonce,
|
||||
sealedIdentity,
|
||||
);
|
||||
if (inputError) throw inputError;
|
||||
state = "published";
|
||||
} catch (error) {
|
||||
state = "guarding";
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const commit = async (): Promise<void> => {
|
||||
if (state !== "published") throw new Error("provider guardian lease is not ready to commit");
|
||||
state = "committing";
|
||||
child.stdin!.write(encodeProviderGuardianCommit(nonce));
|
||||
child.stdin!.end();
|
||||
let result: GuardianResult;
|
||||
try {
|
||||
result = await waitForClose(completion, child);
|
||||
} catch (error) {
|
||||
state = "terminated";
|
||||
return await cleanupFallbackCloseAndThrow(fallback, recovery, error);
|
||||
}
|
||||
state = "terminated";
|
||||
if (inputError) return await cleanupFallbackCloseAndThrow(fallback, recovery, inputError);
|
||||
if (result.error || result.code !== 0 || result.signal !== null) {
|
||||
return await cleanupFallbackCloseAndThrow(
|
||||
fallback,
|
||||
recovery,
|
||||
guardianCloseError(result, stderr),
|
||||
);
|
||||
}
|
||||
await closeRecoveryAuthority(recovery);
|
||||
};
|
||||
|
||||
const abort = async (): Promise<void> => {
|
||||
if (state !== "guarding" && state !== "published") {
|
||||
throw new Error("provider guardian lease already terminated");
|
||||
}
|
||||
state = "aborting";
|
||||
child.stdin!.end();
|
||||
let closeError: unknown;
|
||||
try {
|
||||
await waitForClose(completion, child);
|
||||
} catch (error) {
|
||||
closeError = error;
|
||||
}
|
||||
state = "terminated";
|
||||
if (closeError) return await cleanupFallbackCloseAndThrow(fallback, recovery, closeError);
|
||||
await cleanupFallbackAndClose(fallback, recovery);
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
pid: child.pid,
|
||||
rawPath,
|
||||
rawIdentity,
|
||||
sealedPath,
|
||||
sealedTempPath,
|
||||
sealedIdentity,
|
||||
prematureExit,
|
||||
publish,
|
||||
commit,
|
||||
abort,
|
||||
});
|
||||
}
|
||||
|
||||
async function writePinnedSealedBytes(
|
||||
handle: FileHandle,
|
||||
identity: OwnedIdentity,
|
||||
bytes: Buffer,
|
||||
): Promise<void> {
|
||||
assertPinnedMetadata(await handle.stat(), identity, 0o600, 0);
|
||||
await handle.truncate(0);
|
||||
await handle.writeFile(bytes);
|
||||
await handle.chmod(0o400);
|
||||
await handle.sync();
|
||||
assertPinnedMetadata(await handle.stat(), identity, 0o400, bytes.byteLength);
|
||||
}
|
||||
|
||||
function assertPinnedMetadata(
|
||||
metadata: Awaited<ReturnType<Awaited<ReturnType<typeof open>>["stat"]>>,
|
||||
identity: OwnedIdentity,
|
||||
mode: number,
|
||||
size: number,
|
||||
): void {
|
||||
if (
|
||||
!metadata.isFile() || Number(metadata.dev) !== identity.dev ||
|
||||
Number(metadata.ino) !== identity.ino || Number(metadata.nlink) !== 1 ||
|
||||
(Number(metadata.mode) & 0o777) !== mode || Number(metadata.size) !== size
|
||||
) {
|
||||
throw new TypeError("provider guardian sealed temp identity changed");
|
||||
}
|
||||
}
|
||||
|
||||
function guardianCompletion(child: ReturnType<typeof spawn>): Promise<GuardianResult> {
|
||||
return new Promise((resolve) => {
|
||||
child.once("error", (error) => resolve({ code: null, error, signal: null }));
|
||||
child.once("close", (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForFrame(
|
||||
stdout: NodeJS.ReadableStream,
|
||||
completion: Promise<GuardianResult>,
|
||||
label: string,
|
||||
): Promise<Buffer> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let pending = Buffer.alloc(0);
|
||||
const response = new Promise<Buffer>((resolve, reject) => {
|
||||
const onData = (chunk: Buffer | string): void => {
|
||||
pending = Buffer.concat([pending, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
|
||||
if (pending.byteLength > MAX_CONTROL_OUTPUT_BYTES) {
|
||||
reject(new Error(`provider guardian ${label} output exceeded its bound`));
|
||||
return;
|
||||
}
|
||||
if (pending.byteLength < 4) return;
|
||||
const payloadBytes = pending.readUInt32BE(0);
|
||||
if (payloadBytes <= 0 || payloadBytes > MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES) {
|
||||
reject(new Error(`provider guardian ${label} frame length is invalid`));
|
||||
return;
|
||||
}
|
||||
if (pending.byteLength < payloadBytes + 4) return;
|
||||
if (pending.byteLength !== payloadBytes + 4) {
|
||||
reject(new Error(`provider guardian ${label} output has trailing bytes`));
|
||||
return;
|
||||
}
|
||||
resolve(pending.subarray(4));
|
||||
};
|
||||
stdout.on("data", onData);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([
|
||||
response,
|
||||
completion.then((result) => { throw guardianCloseError(result, Buffer.alloc(0)); }),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error(`provider guardian ${label} timed out`)),
|
||||
RESPONSE_TIMEOUT_MS,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
stdout.removeAllListeners("data");
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForClose(
|
||||
completion: Promise<GuardianResult>,
|
||||
child: ReturnType<typeof spawn>,
|
||||
): Promise<GuardianResult> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
completion,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error("provider guardian did not close within its bound"));
|
||||
}, CLOSE_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
type FallbackIdentity = Readonly<{
|
||||
rawStagingPath: string;
|
||||
rawPath: string;
|
||||
rawIdentity: OwnedIdentity;
|
||||
sealedPath: string;
|
||||
sealedTempPath: string;
|
||||
sealedIdentity: OwnedIdentity;
|
||||
}>;
|
||||
|
||||
async function cleanupFallback(input: FallbackIdentity): Promise<void> {
|
||||
const failures: Error[] = [];
|
||||
for (const target of [
|
||||
{ path: input.rawStagingPath, identity: input.rawIdentity },
|
||||
{ path: input.rawPath, identity: input.rawIdentity },
|
||||
{ path: input.sealedTempPath, identity: input.sealedIdentity },
|
||||
{ path: input.sealedPath, identity: input.sealedIdentity },
|
||||
]) {
|
||||
try {
|
||||
await cleanupOwnedProviderReport({
|
||||
reportPath: target.path,
|
||||
reportDev: target.identity.dev,
|
||||
reportIno: target.identity.ino,
|
||||
});
|
||||
} catch (error) {
|
||||
failures.push(toError(error));
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, "provider guardian fallback cleanup failed", {
|
||||
cause: failures[0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupFallbackCloseAndThrow(
|
||||
fallback: FallbackIdentity,
|
||||
recovery: RecoveryAuthority,
|
||||
primaryError: unknown,
|
||||
): Promise<never> {
|
||||
const failures = [toError(primaryError)];
|
||||
try {
|
||||
await cleanupFallback(fallback);
|
||||
} catch (cleanupError) {
|
||||
failures.push(toError(cleanupError));
|
||||
}
|
||||
try {
|
||||
await closeRecoveryAuthority(recovery);
|
||||
} catch (closeError) {
|
||||
failures.push(toError(closeError));
|
||||
}
|
||||
if (failures.length > 1) {
|
||||
throw new AggregateError(failures, "provider guardian failure and recovery failed", {
|
||||
cause: failures[0],
|
||||
});
|
||||
}
|
||||
throw failures[0]!;
|
||||
}
|
||||
|
||||
async function cleanupFallbackAndClose(
|
||||
fallback: FallbackIdentity,
|
||||
recovery: RecoveryAuthority,
|
||||
): Promise<void> {
|
||||
const failures: Error[] = [];
|
||||
try {
|
||||
await cleanupFallback(fallback);
|
||||
} catch (cleanupError) {
|
||||
failures.push(toError(cleanupError));
|
||||
}
|
||||
try {
|
||||
await closeRecoveryAuthority(recovery);
|
||||
} catch (closeError) {
|
||||
failures.push(toError(closeError));
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, "provider guardian abort recovery failed", {
|
||||
cause: failures[0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function openRecoveryAuthority(input: Readonly<{
|
||||
rawDirectory: string;
|
||||
evidenceRoot: string;
|
||||
rawLeaf: string;
|
||||
rawStagingLeaf: string;
|
||||
sealedLeaf: string;
|
||||
sealedTempLeaf: string;
|
||||
}>): Promise<RecoveryAuthority> {
|
||||
let rawDirectoryHandle: FileHandle | undefined;
|
||||
let evidenceDirectoryHandle: FileHandle | undefined;
|
||||
let rawStagingHandle: FileHandle | undefined;
|
||||
let sealedTempHandle: FileHandle | undefined;
|
||||
let rawIdentity: OwnedIdentity | undefined;
|
||||
let sealedIdentity: OwnedIdentity | undefined;
|
||||
let rawStagingPinnedPath: string | undefined;
|
||||
let rawPinnedPath: string | undefined;
|
||||
let sealedTempPinnedPath: string | undefined;
|
||||
let sealedPinnedPath: string | undefined;
|
||||
try {
|
||||
rawDirectoryHandle = await open(
|
||||
input.rawDirectory,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
await assertPinnedDirectory(rawDirectoryHandle, input.rawDirectory, "raw");
|
||||
evidenceDirectoryHandle = await open(
|
||||
input.evidenceRoot,
|
||||
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
|
||||
);
|
||||
await assertPinnedDirectory(evidenceDirectoryHandle, input.evidenceRoot, "evidence");
|
||||
rawStagingPinnedPath =
|
||||
`/proc/self/fd/${rawDirectoryHandle.fd}/${input.rawStagingLeaf}`;
|
||||
rawPinnedPath = `/proc/self/fd/${rawDirectoryHandle.fd}/${input.rawLeaf}`;
|
||||
sealedTempPinnedPath =
|
||||
`/proc/self/fd/${evidenceDirectoryHandle.fd}/${input.sealedTempLeaf}`;
|
||||
sealedPinnedPath = `/proc/self/fd/${evidenceDirectoryHandle.fd}/${input.sealedLeaf}`;
|
||||
rawStagingHandle = await open(
|
||||
rawStagingPinnedPath,
|
||||
constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
const rawMetadata = await rawStagingHandle.stat();
|
||||
rawIdentity = Object.freeze({ dev: rawMetadata.dev, ino: rawMetadata.ino });
|
||||
assertAllocatedPrivateMetadata(rawMetadata, rawIdentity, "raw staging");
|
||||
await assertPinnedLeafIdentity(rawStagingPinnedPath, rawIdentity, 0o600);
|
||||
sealedTempHandle = await open(
|
||||
sealedTempPinnedPath,
|
||||
constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
const sealedMetadata = await sealedTempHandle.stat();
|
||||
sealedIdentity = Object.freeze({ dev: sealedMetadata.dev, ino: sealedMetadata.ino });
|
||||
assertAllocatedPrivateMetadata(sealedMetadata, sealedIdentity, "sealed temp");
|
||||
await assertPinnedLeafIdentity(sealedTempPinnedPath, sealedIdentity, 0o600);
|
||||
return Object.freeze({
|
||||
rawDirectoryHandle,
|
||||
evidenceDirectoryHandle,
|
||||
rawStagingHandle,
|
||||
sealedTempHandle,
|
||||
rawIdentity,
|
||||
sealedIdentity,
|
||||
rawStagingPinnedPath,
|
||||
rawPinnedPath,
|
||||
sealedTempPinnedPath,
|
||||
sealedPinnedPath,
|
||||
});
|
||||
} catch (error) {
|
||||
const failures = [toError(error)];
|
||||
for (const target of [
|
||||
{ path: rawStagingPinnedPath, identity: rawIdentity },
|
||||
{ path: rawPinnedPath, identity: rawIdentity },
|
||||
{ path: sealedTempPinnedPath, identity: sealedIdentity },
|
||||
{ path: sealedPinnedPath, identity: sealedIdentity },
|
||||
]) {
|
||||
if (!target.path || !target.identity) continue;
|
||||
try {
|
||||
await cleanupOwnedProviderReport({
|
||||
reportPath: target.path,
|
||||
reportDev: target.identity.dev,
|
||||
reportIno: target.identity.ino,
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
failures.push(toError(cleanupError));
|
||||
}
|
||||
}
|
||||
for (const handle of [
|
||||
sealedTempHandle,
|
||||
rawStagingHandle,
|
||||
evidenceDirectoryHandle,
|
||||
rawDirectoryHandle,
|
||||
]) {
|
||||
if (!handle) continue;
|
||||
try { await handle.close(); } catch (closeError) { failures.push(toError(closeError)); }
|
||||
}
|
||||
if (failures.length > 1) {
|
||||
throw new AggregateError(failures, "provider guardian recovery setup failed", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw failures[0]!;
|
||||
}
|
||||
}
|
||||
|
||||
function assertAllocatedPrivateMetadata(
|
||||
metadata: Awaited<ReturnType<FileHandle["stat"]>>,
|
||||
identity: OwnedIdentity,
|
||||
label: string,
|
||||
): void {
|
||||
if (
|
||||
!metadata.isFile() || Number(metadata.dev) !== identity.dev ||
|
||||
Number(metadata.ino) !== identity.ino || Number(metadata.nlink) !== 1 ||
|
||||
(Number(metadata.mode) & 0o777) !== 0o600 || Number(metadata.size) !== 0
|
||||
) {
|
||||
throw new TypeError(`provider guardian ${label} allocation is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPinnedDirectory(
|
||||
handle: FileHandle,
|
||||
canonicalPath: string,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
const [descriptorMetadata, pathMetadata] = await Promise.all([
|
||||
handle.stat(),
|
||||
lstat(canonicalPath),
|
||||
]);
|
||||
if (
|
||||
!descriptorMetadata.isDirectory() || !pathMetadata.isDirectory() ||
|
||||
pathMetadata.isSymbolicLink() || descriptorMetadata.dev !== pathMetadata.dev ||
|
||||
descriptorMetadata.ino !== pathMetadata.ino
|
||||
) {
|
||||
throw new TypeError(`provider guardian ${label} recovery directory identity changed`);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertRecoveryLeavesMissing(recovery: RecoveryAuthority): Promise<void> {
|
||||
await assertPinnedLeafMissing(recovery.rawPinnedPath);
|
||||
await assertPinnedLeafMissing(recovery.sealedPinnedPath);
|
||||
assertAllocatedPrivateMetadata(
|
||||
await recovery.rawStagingHandle.stat(),
|
||||
recovery.rawIdentity,
|
||||
"raw staging",
|
||||
);
|
||||
assertAllocatedPrivateMetadata(
|
||||
await recovery.sealedTempHandle.stat(),
|
||||
recovery.sealedIdentity,
|
||||
"sealed temp",
|
||||
);
|
||||
await assertPinnedLeafIdentity(
|
||||
recovery.rawStagingPinnedPath,
|
||||
recovery.rawIdentity,
|
||||
0o600,
|
||||
);
|
||||
await assertPinnedLeafIdentity(
|
||||
recovery.sealedTempPinnedPath,
|
||||
recovery.sealedIdentity,
|
||||
0o600,
|
||||
);
|
||||
}
|
||||
|
||||
async function assertPinnedLeafMissing(target: string): Promise<void> {
|
||||
try {
|
||||
await lstat(target);
|
||||
throw new Error("provider guardian transaction leaf already exists");
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "ENOENT")) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPinnedLeafIdentity(
|
||||
target: string,
|
||||
identity: OwnedIdentity,
|
||||
mode: number,
|
||||
): Promise<void> {
|
||||
const metadata = await lstat(target);
|
||||
if (
|
||||
!metadata.isFile() || metadata.isSymbolicLink() || metadata.dev !== identity.dev ||
|
||||
metadata.ino !== identity.ino || metadata.nlink !== 1 ||
|
||||
(metadata.mode & 0o777) !== mode || metadata.size !== 0
|
||||
) {
|
||||
throw new TypeError("provider guardian READY identity changed");
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupStartupRecovery(recovery: RecoveryAuthority): Promise<void> {
|
||||
await cleanupFallback({
|
||||
rawStagingPath: recovery.rawStagingPinnedPath,
|
||||
rawPath: recovery.rawPinnedPath,
|
||||
rawIdentity: recovery.rawIdentity,
|
||||
sealedTempPath: recovery.sealedTempPinnedPath,
|
||||
sealedPath: recovery.sealedPinnedPath,
|
||||
sealedIdentity: recovery.sealedIdentity,
|
||||
});
|
||||
}
|
||||
|
||||
async function closeRecoveryAuthority(recovery: RecoveryAuthority): Promise<void> {
|
||||
const failures: Error[] = [];
|
||||
for (const handle of [
|
||||
recovery.rawStagingHandle,
|
||||
recovery.sealedTempHandle,
|
||||
recovery.rawDirectoryHandle,
|
||||
recovery.evidenceDirectoryHandle,
|
||||
]) {
|
||||
try { await handle.close(); } catch (error) { failures.push(toError(error)); }
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, "provider guardian recovery directory close failed", {
|
||||
cause: failures[0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function closeRecoveryAndThrow(
|
||||
recovery: RecoveryAuthority,
|
||||
primaryError: unknown,
|
||||
): Promise<never> {
|
||||
const failures = [toError(primaryError)];
|
||||
try {
|
||||
await cleanupStartupRecovery(recovery);
|
||||
} catch (cleanupError) {
|
||||
failures.push(toError(cleanupError));
|
||||
}
|
||||
try {
|
||||
await closeRecoveryAuthority(recovery);
|
||||
} catch (closeError) {
|
||||
failures.push(toError(closeError));
|
||||
}
|
||||
if (failures.length > 1) {
|
||||
throw new AggregateError(failures,
|
||||
"provider guardian failure and recovery close failed", { cause: failures[0] });
|
||||
}
|
||||
throw failures[0]!;
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
||||
}
|
||||
|
||||
function guardianCloseError(result: GuardianResult, stderr: Buffer): Error {
|
||||
if (result.error) return result.error;
|
||||
const detail = stderr.toString("utf8").trim();
|
||||
return new Error(
|
||||
`provider guardian failed: exit=${result.code ?? "none"}, signal=${result.signal ?? "none"}${detail ? `, output=${detail}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
|
||||
export type ProviderGuardianKind = "vulnerability" | "provenance";
|
||||
|
||||
export const MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES = 4_092;
|
||||
export const MAX_PROVIDER_GUARDIAN_LEASE_MS = 40 * 60 * 1_000;
|
||||
export const MAX_PROVIDER_SEALED_BYTES = 8_388_608;
|
||||
const V2_GUARD_KEYS = ["type", "version", "kind", "nonce", "deadlineEpochMs"] as const;
|
||||
const READY_KEYS = [
|
||||
"type", "version", "nonce", "rawDev", "rawIno",
|
||||
"sealedTempLeaf", "sealedDev", "sealedIno",
|
||||
] as const;
|
||||
const PUBLISH_KEYS = [
|
||||
"type", "version", "nonce", "sealedDev", "sealedIno", "size", "sha256",
|
||||
] as const;
|
||||
const PUBLISHED_KEYS = ["type", "version", "nonce", "sealedDev", "sealedIno"] as const;
|
||||
const COMMIT_KEYS = ["type", "version", "nonce"] as const;
|
||||
|
||||
export type ProviderGuardianGuard = Readonly<{
|
||||
kind: ProviderGuardianKind;
|
||||
nonce: Buffer;
|
||||
deadlineEpochMs: number;
|
||||
}>;
|
||||
|
||||
export type ProviderGuardianReady = Readonly<{
|
||||
nonce: Buffer;
|
||||
rawDev: number;
|
||||
rawIno: number;
|
||||
sealedTempLeaf: string;
|
||||
sealedDev: number;
|
||||
sealedIno: number;
|
||||
}>;
|
||||
|
||||
export type ProviderGuardianPublish = Readonly<{
|
||||
nonce: Buffer;
|
||||
sealedDev: number;
|
||||
sealedIno: number;
|
||||
size: number;
|
||||
sha256: string;
|
||||
}>;
|
||||
|
||||
export type ProviderGuardianPublished = Readonly<{
|
||||
nonce: Buffer;
|
||||
sealedDev: number;
|
||||
sealedIno: number;
|
||||
}>;
|
||||
|
||||
export function providerGuardianSealedTempLeaf(
|
||||
kind: ProviderGuardianKind,
|
||||
nonce: Buffer,
|
||||
): string {
|
||||
const rawLeaf = baseLeaf(kind);
|
||||
assertV2Nonce(nonce);
|
||||
return `.${rawLeaf}.guardian-${nonce.subarray(0, 16).toString("hex")}.tmp`;
|
||||
}
|
||||
|
||||
export function providerGuardianRawStagingLeaf(
|
||||
kind: ProviderGuardianKind,
|
||||
nonce: Buffer,
|
||||
): string {
|
||||
const rawLeaf = baseLeaf(kind);
|
||||
assertV2Nonce(nonce);
|
||||
return `.${rawLeaf}.guardian-${nonce.subarray(0, 16).toString("hex")}.raw.tmp`;
|
||||
}
|
||||
|
||||
export function encodeProviderGuardianGuard(input: ProviderGuardianGuard): Buffer {
|
||||
assertV2Guard(input);
|
||||
return prefixFrame(encodeV2GuardPayload(input));
|
||||
}
|
||||
|
||||
export function decodeProviderGuardianGuard(
|
||||
payload: Buffer,
|
||||
options: Readonly<{ nowEpochMs: number; maxLeaseMs: number }>,
|
||||
): ProviderGuardianGuard {
|
||||
const value = parseRecord(payload, V2_GUARD_KEYS, "guard");
|
||||
const guard: ProviderGuardianGuard = {
|
||||
kind: value.kind as ProviderGuardianKind,
|
||||
nonce: parseV2Nonce(value.nonce),
|
||||
deadlineEpochMs: value.deadlineEpochMs as number,
|
||||
};
|
||||
if (value.type !== "guard" || value.version !== 2) {
|
||||
throw new TypeError("provider guardian guard version is invalid");
|
||||
}
|
||||
assertV2Guard(guard);
|
||||
if (
|
||||
!Number.isSafeInteger(options.nowEpochMs) ||
|
||||
!Number.isSafeInteger(options.maxLeaseMs) || options.maxLeaseMs <= 0 ||
|
||||
guard.deadlineEpochMs <= options.nowEpochMs ||
|
||||
guard.deadlineEpochMs > options.nowEpochMs + options.maxLeaseMs
|
||||
) {
|
||||
throw new TypeError("provider guardian guard deadline is invalid");
|
||||
}
|
||||
assertCanonical(payload, encodeV2GuardPayload(guard), "guard");
|
||||
return guard;
|
||||
}
|
||||
|
||||
export function encodeProviderGuardianReady(input: ProviderGuardianReady): Buffer {
|
||||
assertReady(input);
|
||||
return prefixFrame(encodeReadyPayload(input));
|
||||
}
|
||||
|
||||
export function decodeProviderGuardianReady(
|
||||
payload: Buffer,
|
||||
expectedNonce: Buffer,
|
||||
): ProviderGuardianReady {
|
||||
assertV2Nonce(expectedNonce);
|
||||
const value = parseRecord(payload, READY_KEYS, "READY");
|
||||
const ready: ProviderGuardianReady = {
|
||||
nonce: parseV2Nonce(value.nonce),
|
||||
rawDev: value.rawDev as number,
|
||||
rawIno: value.rawIno as number,
|
||||
sealedTempLeaf: value.sealedTempLeaf as string,
|
||||
sealedDev: value.sealedDev as number,
|
||||
sealedIno: value.sealedIno as number,
|
||||
};
|
||||
if (value.type !== "ready" || value.version !== 2) {
|
||||
throw new TypeError("provider guardian READY version is invalid");
|
||||
}
|
||||
assertReady(ready);
|
||||
assertAuthenticatedNonce(ready.nonce, expectedNonce, "READY");
|
||||
assertCanonical(payload, encodeReadyPayload(ready), "READY");
|
||||
return ready;
|
||||
}
|
||||
|
||||
export function encodeProviderGuardianPublish(input: ProviderGuardianPublish): Buffer {
|
||||
assertPublish(input);
|
||||
return prefixFrame(encodePublishPayload(input));
|
||||
}
|
||||
|
||||
export function decodeProviderGuardianPublish(
|
||||
payload: Buffer,
|
||||
expectedNonce: Buffer,
|
||||
): ProviderGuardianPublish {
|
||||
assertV2Nonce(expectedNonce);
|
||||
const value = parseRecord(payload, PUBLISH_KEYS, "publish");
|
||||
const publish: ProviderGuardianPublish = {
|
||||
nonce: parseV2Nonce(value.nonce),
|
||||
sealedDev: value.sealedDev as number,
|
||||
sealedIno: value.sealedIno as number,
|
||||
size: value.size as number,
|
||||
sha256: value.sha256 as string,
|
||||
};
|
||||
if (value.type !== "publish" || value.version !== 2) {
|
||||
throw new TypeError("provider guardian publish version is invalid");
|
||||
}
|
||||
assertPublish(publish);
|
||||
assertAuthenticatedNonce(publish.nonce, expectedNonce, "publish");
|
||||
assertCanonical(payload, encodePublishPayload(publish), "publish");
|
||||
return publish;
|
||||
}
|
||||
|
||||
export function encodeProviderGuardianPublished(input: ProviderGuardianPublished): Buffer {
|
||||
assertPublished(input);
|
||||
return prefixFrame(encodePublishedPayload(input));
|
||||
}
|
||||
|
||||
export function decodeProviderGuardianPublished(
|
||||
payload: Buffer,
|
||||
expectedNonce: Buffer,
|
||||
expectedIdentity: Readonly<{ dev: number; ino: number }>,
|
||||
): void {
|
||||
assertV2Nonce(expectedNonce);
|
||||
const value = parseRecord(payload, PUBLISHED_KEYS, "PUBLISHED");
|
||||
const published: ProviderGuardianPublished = {
|
||||
nonce: parseV2Nonce(value.nonce),
|
||||
sealedDev: value.sealedDev as number,
|
||||
sealedIno: value.sealedIno as number,
|
||||
};
|
||||
if (value.type !== "published" || value.version !== 2) {
|
||||
throw new TypeError("provider guardian PUBLISHED version is invalid");
|
||||
}
|
||||
assertPublished(published);
|
||||
assertAuthenticatedNonce(published.nonce, expectedNonce, "PUBLISHED");
|
||||
if (published.sealedDev !== expectedIdentity.dev || published.sealedIno !== expectedIdentity.ino) {
|
||||
throw new TypeError("provider guardian PUBLISHED identity is invalid");
|
||||
}
|
||||
assertCanonical(payload, encodePublishedPayload(published), "PUBLISHED");
|
||||
}
|
||||
|
||||
function encodeV2GuardPayload(input: ProviderGuardianGuard): Buffer {
|
||||
return Buffer.from(JSON.stringify({
|
||||
type: "guard",
|
||||
version: 2,
|
||||
kind: input.kind,
|
||||
nonce: input.nonce.toString("hex"),
|
||||
deadlineEpochMs: input.deadlineEpochMs,
|
||||
}));
|
||||
}
|
||||
|
||||
export function encodeProviderGuardianCommit(nonce: Buffer): Buffer {
|
||||
assertV2Nonce(nonce);
|
||||
return prefixFrame(encodeCommitPayload(nonce));
|
||||
}
|
||||
|
||||
export function decodeProviderGuardianCommit(payload: Buffer, expectedNonce: Buffer): void {
|
||||
assertPayloadSize(payload);
|
||||
assertV2Nonce(expectedNonce);
|
||||
const decoded = decodeUtf8(payload);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(decoded);
|
||||
} catch {
|
||||
throw new TypeError("provider guardian commit JSON is invalid");
|
||||
}
|
||||
if (!isRecord(value) || !hasExactKeys(value, COMMIT_KEYS)) {
|
||||
throw new TypeError("provider guardian commit fields are invalid");
|
||||
}
|
||||
const nonce = parseV2Nonce(value.nonce);
|
||||
if (
|
||||
value.type !== "commit" || value.version !== 2 ||
|
||||
nonce.byteLength !== expectedNonce.byteLength ||
|
||||
!timingSafeEqual(nonce, expectedNonce)
|
||||
) {
|
||||
throw new TypeError("provider guardian commit authentication failed");
|
||||
}
|
||||
if (!payload.equals(encodeCommitPayload(nonce))) {
|
||||
throw new TypeError("provider guardian commit is not canonical");
|
||||
}
|
||||
}
|
||||
|
||||
function encodeCommitPayload(nonce: Buffer): Buffer {
|
||||
return Buffer.from(JSON.stringify({
|
||||
type: "commit",
|
||||
version: 2,
|
||||
nonce: nonce.toString("hex"),
|
||||
}));
|
||||
}
|
||||
|
||||
function encodeReadyPayload(input: ProviderGuardianReady): Buffer {
|
||||
return Buffer.from(JSON.stringify({
|
||||
type: "ready",
|
||||
version: 2,
|
||||
nonce: input.nonce.toString("hex"),
|
||||
rawDev: input.rawDev,
|
||||
rawIno: input.rawIno,
|
||||
sealedTempLeaf: input.sealedTempLeaf,
|
||||
sealedDev: input.sealedDev,
|
||||
sealedIno: input.sealedIno,
|
||||
}));
|
||||
}
|
||||
|
||||
function encodePublishPayload(input: ProviderGuardianPublish): Buffer {
|
||||
return Buffer.from(JSON.stringify({
|
||||
type: "publish",
|
||||
version: 2,
|
||||
nonce: input.nonce.toString("hex"),
|
||||
sealedDev: input.sealedDev,
|
||||
sealedIno: input.sealedIno,
|
||||
size: input.size,
|
||||
sha256: input.sha256,
|
||||
}));
|
||||
}
|
||||
|
||||
function encodePublishedPayload(input: ProviderGuardianPublished): Buffer {
|
||||
return Buffer.from(JSON.stringify({
|
||||
type: "published",
|
||||
version: 2,
|
||||
nonce: input.nonce.toString("hex"),
|
||||
sealedDev: input.sealedDev,
|
||||
sealedIno: input.sealedIno,
|
||||
}));
|
||||
}
|
||||
|
||||
function prefixFrame(payload: Buffer): Buffer {
|
||||
if (payload.byteLength <= 0 || payload.byteLength > MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES) {
|
||||
throw new TypeError("provider guardian frame size is invalid");
|
||||
}
|
||||
const frame = Buffer.allocUnsafe(payload.byteLength + 4);
|
||||
frame.writeUInt32BE(payload.byteLength, 0);
|
||||
payload.copy(frame, 4);
|
||||
return frame;
|
||||
}
|
||||
|
||||
function assertV2Guard(input: ProviderGuardianGuard): void {
|
||||
if (
|
||||
(input.kind !== "vulnerability" && input.kind !== "provenance") ||
|
||||
!isV2Nonce(input.nonce) ||
|
||||
!Number.isSafeInteger(input.deadlineEpochMs) || input.deadlineEpochMs <= 0
|
||||
) {
|
||||
throw new TypeError("provider guardian guard fields are invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function baseLeaf(kind: ProviderGuardianKind): string {
|
||||
if (kind === "vulnerability") return "vulnerability-report.json";
|
||||
if (kind === "provenance") return "provenance-attestation.json";
|
||||
throw new TypeError("provider guardian kind is invalid");
|
||||
}
|
||||
|
||||
function assertReady(input: ProviderGuardianReady): void {
|
||||
if (
|
||||
!isV2Nonce(input.nonce) ||
|
||||
!isIdentityPart(input.rawDev) || !isIdentityPart(input.rawIno) ||
|
||||
typeof input.sealedTempLeaf !== "string" ||
|
||||
!/^\.(?:vulnerability-report|provenance-attestation)\.json\.guardian-[0-9a-f]{32}\.tmp$/u
|
||||
.test(input.sealedTempLeaf) ||
|
||||
!isIdentityPart(input.sealedDev) || !isIdentityPart(input.sealedIno)
|
||||
) {
|
||||
throw new TypeError("provider guardian READY fields are invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function assertPublish(input: ProviderGuardianPublish): void {
|
||||
if (
|
||||
!isV2Nonce(input.nonce) ||
|
||||
!isIdentityPart(input.sealedDev) || !isIdentityPart(input.sealedIno) ||
|
||||
!Number.isSafeInteger(input.size) || input.size <= 0 || input.size > MAX_PROVIDER_SEALED_BYTES ||
|
||||
typeof input.sha256 !== "string" || !/^[0-9a-f]{64}$/u.test(input.sha256)
|
||||
) {
|
||||
throw new TypeError("provider guardian publish fields are invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function assertPublished(input: ProviderGuardianPublished): void {
|
||||
if (
|
||||
!isV2Nonce(input.nonce) ||
|
||||
!isIdentityPart(input.sealedDev) || !isIdentityPart(input.sealedIno)
|
||||
) {
|
||||
throw new TypeError("provider guardian PUBLISHED fields are invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function assertV2Nonce(nonce: Buffer): void {
|
||||
if (!isV2Nonce(nonce)) throw new TypeError("provider guardian nonce is invalid");
|
||||
}
|
||||
|
||||
function isV2Nonce(nonce: Buffer): boolean {
|
||||
return Buffer.isBuffer(nonce) && nonce.byteLength === 32;
|
||||
}
|
||||
|
||||
function parseV2Nonce(value: unknown): Buffer {
|
||||
return typeof value === "string" && /^[0-9a-f]{64}$/u.test(value)
|
||||
? Buffer.from(value, "hex")
|
||||
: Buffer.alloc(0);
|
||||
}
|
||||
|
||||
function isIdentityPart(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) > 0;
|
||||
}
|
||||
|
||||
function assertAuthenticatedNonce(received: Buffer, expected: Buffer, label: string): void {
|
||||
if (received.byteLength !== expected.byteLength || !timingSafeEqual(received, expected)) {
|
||||
throw new TypeError(`provider guardian ${label} authentication failed`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseRecord(
|
||||
payload: Buffer,
|
||||
expectedKeys: readonly string[],
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
assertPayloadSize(payload);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(decodeUtf8(payload));
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError && /provider guardian/u.test(error.message)) throw error;
|
||||
throw new TypeError(`provider guardian ${label} JSON is invalid`, { cause: error });
|
||||
}
|
||||
if (!isRecord(value) || !hasExactKeys(value, expectedKeys)) {
|
||||
throw new TypeError(`provider guardian ${label} fields are invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertCanonical(payload: Buffer, canonical: Buffer, label: string): void {
|
||||
if (!payload.equals(canonical)) {
|
||||
throw new TypeError(`provider guardian ${label} frame is not canonical`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPayloadSize(payload: Buffer): void {
|
||||
if (!Buffer.isBuffer(payload) || payload.byteLength <= 0 || payload.byteLength > MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES) {
|
||||
throw new TypeError("provider guardian frame size is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function decodeUtf8(payload: Buffer): string {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = new TextDecoder("utf-8", { fatal: true }).decode(payload);
|
||||
} catch {
|
||||
throw new TypeError("provider guardian frame UTF-8 is invalid");
|
||||
}
|
||||
if (decoded.includes("\0")) throw new TypeError("provider guardian frame contains NUL");
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function hasExactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
|
||||
const keys = Object.keys(value);
|
||||
return keys.length === expected.length && keys.every((key, index) => key === expected[index]);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export type ProviderOutputLimiter = Readonly<{
|
||||
consume(chunk: Buffer | string): void;
|
||||
bytes(): number;
|
||||
}>;
|
||||
|
||||
export function createProviderOutputLimiter(
|
||||
maxBytes: number,
|
||||
onExceeded: () => void,
|
||||
): ProviderOutputLimiter {
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0 || typeof onExceeded !== "function") {
|
||||
throw new TypeError("provider output limiter input is invalid");
|
||||
}
|
||||
let observedBytes = 0;
|
||||
let exceeded = false;
|
||||
return Object.freeze({
|
||||
consume: (chunk: Buffer | string) => {
|
||||
if (exceeded) return;
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk.byteLength : Buffer.byteLength(chunk);
|
||||
observedBytes += bytes;
|
||||
if (observedBytes > maxBytes) {
|
||||
exceeded = true;
|
||||
onExceeded();
|
||||
}
|
||||
},
|
||||
bytes: () => observedBytes,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
|
||||
export type ProviderProcessInput = Readonly<{
|
||||
executable: string;
|
||||
arguments: readonly string[];
|
||||
environment: NodeJS.ProcessEnv;
|
||||
timeoutMs: number;
|
||||
}>;
|
||||
|
||||
type ProviderChild = Pick<ChildProcess, "kill" | "once" | "pid">;
|
||||
|
||||
export async function runProviderProcess(
|
||||
input: ProviderProcessInput,
|
||||
dependencies: Readonly<{
|
||||
spawnChild?: (input: ProviderProcessInput) => ProviderChild;
|
||||
setTimer?: (callback: () => void, milliseconds: number) => ReturnType<typeof setTimeout>;
|
||||
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
|
||||
killProcessGroup?: (child: ProviderChild) => void;
|
||||
}> = {},
|
||||
): Promise<void> {
|
||||
if (!Number.isSafeInteger(input.timeoutMs) || input.timeoutMs <= 0) {
|
||||
throw new TypeError("provider process timeout must be a positive integer");
|
||||
}
|
||||
const child = (dependencies.spawnChild ?? defaultSpawn)(input);
|
||||
const setTimer = dependencies.setTimer ?? setTimeout;
|
||||
const clearTimer = dependencies.clearTimer ?? clearTimeout;
|
||||
const killProcessGroup = dependencies.killProcessGroup ?? defaultKillProcessGroup;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
const killFailures: unknown[] = [];
|
||||
const settle = (error?: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimer(timer);
|
||||
error ? reject(error) : resolve();
|
||||
};
|
||||
const timer = setTimer(() => {
|
||||
timedOut = true;
|
||||
try {
|
||||
killProcessGroup(child);
|
||||
} catch (groupError) {
|
||||
killFailures.push(groupError);
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch (fallbackError) {
|
||||
killFailures.push(fallbackError);
|
||||
}
|
||||
}
|
||||
}, input.timeoutMs);
|
||||
child.once("error", (error: Error) => {
|
||||
if (!timedOut) settle(error);
|
||||
});
|
||||
child.once("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (timedOut) {
|
||||
const timeoutError = new Error(
|
||||
"sandboxed external provider command timed out after process close",
|
||||
);
|
||||
settle(
|
||||
killFailures.length === 0
|
||||
? timeoutError
|
||||
: new AggregateError(
|
||||
[timeoutError, ...killFailures],
|
||||
"sandboxed external provider timed out and process-group kill failed before close",
|
||||
{ cause: killFailures.at(-1) },
|
||||
),
|
||||
);
|
||||
} else if (code === 0 && signal === null) {
|
||||
settle();
|
||||
} else {
|
||||
settle(
|
||||
new Error(
|
||||
`sandboxed external provider failed: exit=${code ?? "none"}, signal=${signal ?? "none"}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function defaultSpawn(input: ProviderProcessInput): ProviderChild {
|
||||
return spawn(input.executable, [...input.arguments], {
|
||||
env: input.environment,
|
||||
stdio: "inherit",
|
||||
detached: true,
|
||||
});
|
||||
}
|
||||
|
||||
function defaultKillProcessGroup(child: ProviderChild): void {
|
||||
if (child.pid && child.pid > 0) {
|
||||
try {
|
||||
process.kill(-child.pid, "SIGKILL");
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "ESRCH")) throw error;
|
||||
}
|
||||
}
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { lstat, rename, unlink } from "node:fs/promises";
|
||||
|
||||
export type ProviderRawIdentity = Readonly<{
|
||||
reportPath: string;
|
||||
reportDev: number;
|
||||
reportIno: number;
|
||||
}>;
|
||||
|
||||
export async function cleanupOwnedProviderReport(
|
||||
identity: ProviderRawIdentity,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const metadata = await lstat(identity.reportPath);
|
||||
if (!matchesReportIdentity(metadata, identity)) return false;
|
||||
const quarantine = `${identity.reportPath}.parent-loss-${process.pid}-${randomBytes(16).toString("hex")}`;
|
||||
await rename(identity.reportPath, quarantine);
|
||||
const quarantinedMetadata = await lstat(quarantine);
|
||||
if (!matchesReportIdentity(quarantinedMetadata, identity)) {
|
||||
throw new Error("provider raw output identity changed during parent-loss cleanup");
|
||||
}
|
||||
await unlink(quarantine);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function matchesReportIdentity(
|
||||
metadata: Awaited<ReturnType<typeof lstat>>,
|
||||
identity: ProviderRawIdentity,
|
||||
): boolean {
|
||||
return metadata.isFile() && !metadata.isSymbolicLink() &&
|
||||
metadata.dev === identity.reportDev && metadata.ino === identity.reportIno;
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
closeSync,
|
||||
fstatSync,
|
||||
fsyncSync,
|
||||
lstatSync,
|
||||
readlinkSync,
|
||||
readSync,
|
||||
type Stats,
|
||||
writeSync,
|
||||
createReadStream,
|
||||
} from "node:fs";
|
||||
import { link, lstat, unlink } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
decodeProviderGuardianCommit,
|
||||
decodeProviderGuardianGuard,
|
||||
decodeProviderGuardianPublish,
|
||||
encodeProviderGuardianPublished,
|
||||
encodeProviderGuardianReady,
|
||||
MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES,
|
||||
MAX_PROVIDER_GUARDIAN_LEASE_MS,
|
||||
providerGuardianRawStagingLeaf,
|
||||
providerGuardianSealedTempLeaf,
|
||||
type ProviderGuardianGuard,
|
||||
type ProviderGuardianKind,
|
||||
} from "./provider-guardian-protocol.ts";
|
||||
import { cleanupOwnedProviderReport } from "./provider-raw-cleanup.ts";
|
||||
|
||||
type OwnedIdentity = Readonly<{ dev: number; ino: number }>;
|
||||
type BootstrapAuthority = Readonly<{
|
||||
kind: ProviderGuardianKind;
|
||||
noncePrefix: string;
|
||||
rawStagingLeaf: string;
|
||||
rawStagingPath: string;
|
||||
rawPath: string;
|
||||
rawIdentity: OwnedIdentity;
|
||||
sealedTempLeaf: string;
|
||||
sealedTempPath: string;
|
||||
sealedPath: string;
|
||||
sealedIdentity: OwnedIdentity;
|
||||
}>;
|
||||
type BoundPrivateAuthority = Readonly<{
|
||||
identity: OwnedIdentity;
|
||||
leaf: string;
|
||||
noncePrefix: string;
|
||||
path: string;
|
||||
stem: string;
|
||||
}>;
|
||||
type GuardianTransaction = Readonly<{ guard: ProviderGuardianGuard }>;
|
||||
|
||||
const RAW_DIRECTORY_FD = 3;
|
||||
const EVIDENCE_DIRECTORY_FD = 4;
|
||||
const RAW_STAGING_FD = 5;
|
||||
const SEALED_TEMP_FD = 6;
|
||||
const RAW_DIRECTORY_PATH = `/proc/self/fd/${RAW_DIRECTORY_FD}`;
|
||||
const EVIDENCE_DIRECTORY_PATH = `/proc/self/fd/${EVIDENCE_DIRECTORY_FD}`;
|
||||
const RAW_STAGING_FD_PATH = `/proc/self/fd/${RAW_STAGING_FD}`;
|
||||
const SEALED_TEMP_FD_PATH = `/proc/self/fd/${SEALED_TEMP_FD}`;
|
||||
const RAW_STAGING_PATTERN =
|
||||
/^\.(vulnerability-report|provenance-attestation)\.json\.guardian-([0-9a-f]{32})\.raw\.tmp$/u;
|
||||
const SEALED_TEMP_PATTERN =
|
||||
/^\.(vulnerability-report|provenance-attestation)\.json\.guardian-([0-9a-f]{32})\.tmp$/u;
|
||||
|
||||
let privateFdsClosed = false;
|
||||
const bootstrap = await initializeBootstrap();
|
||||
let pending = Buffer.alloc(0);
|
||||
let expectedBytes: number | undefined;
|
||||
let state: "starting" | "guarding" | "published" | "commitPending" = "starting";
|
||||
let transaction: GuardianTransaction | undefined;
|
||||
let terminal = false;
|
||||
let deadline: NodeJS.Timeout | undefined;
|
||||
let operations = Promise.resolve();
|
||||
const liveness = createReadStream("", { fd: 0, autoClose: false });
|
||||
|
||||
liveness.on("data", consumeChunk);
|
||||
liveness.once("end", () => {
|
||||
enqueue(async () => {
|
||||
if (state === "commitPending" && pending.byteLength === 0 && expectedBytes === undefined) {
|
||||
await succeedOnCommittedEof();
|
||||
return;
|
||||
}
|
||||
if (state === "starting" && pending.byteLength > 0) {
|
||||
await failClosed(126, "provider guardian frame is truncated");
|
||||
return;
|
||||
}
|
||||
await failClosed(125, "provider guardian liveness EOF");
|
||||
});
|
||||
});
|
||||
liveness.once("error", (error) => {
|
||||
enqueue(async () => failClosed(125, `provider guardian liveness error: ${error.message}`));
|
||||
});
|
||||
|
||||
async function initializeBootstrap(): Promise<BootstrapAuthority> {
|
||||
const rawDirectory = path.resolve(process.cwd(), "provider-evidence/untrusted");
|
||||
const evidenceDirectory = path.resolve(process.cwd(), "provider-evidence");
|
||||
const failures: Error[] = [];
|
||||
const rawDirectoryValid = captureInheritedDirectory(
|
||||
RAW_DIRECTORY_FD,
|
||||
rawDirectory,
|
||||
"raw",
|
||||
failures,
|
||||
);
|
||||
const evidenceDirectoryValid = captureInheritedDirectory(
|
||||
EVIDENCE_DIRECTORY_FD,
|
||||
evidenceDirectory,
|
||||
"evidence",
|
||||
failures,
|
||||
);
|
||||
|
||||
const rawDescriptor = capturePrivateDescriptor(RAW_STAGING_FD, "raw staging", failures);
|
||||
const sealedDescriptor = capturePrivateDescriptor(SEALED_TEMP_FD, "sealed temp", failures);
|
||||
const rawAuthority = rawDescriptor && rawDirectoryValid
|
||||
? capturePrivateAlias({
|
||||
descriptorMetadata: rawDescriptor,
|
||||
descriptorTarget: RAW_STAGING_FD_PATH,
|
||||
expectedDirectory: rawDirectory,
|
||||
descriptorDirectory: RAW_DIRECTORY_PATH,
|
||||
grammar: RAW_STAGING_PATTERN,
|
||||
label: "raw staging",
|
||||
}, failures)
|
||||
: undefined;
|
||||
const sealedAuthority = sealedDescriptor && evidenceDirectoryValid
|
||||
? capturePrivateAlias({
|
||||
descriptorMetadata: sealedDescriptor,
|
||||
descriptorTarget: SEALED_TEMP_FD_PATH,
|
||||
expectedDirectory: evidenceDirectory,
|
||||
descriptorDirectory: EVIDENCE_DIRECTORY_PATH,
|
||||
grammar: SEALED_TEMP_PATTERN,
|
||||
label: "sealed temp",
|
||||
}, failures)
|
||||
: undefined;
|
||||
|
||||
if (!rawAuthority || !sealedAuthority) {
|
||||
return await failBootstrap(rawAuthority, sealedAuthority, failures);
|
||||
}
|
||||
try {
|
||||
if (
|
||||
rawAuthority.stem !== sealedAuthority.stem ||
|
||||
rawAuthority.noncePrefix !== sealedAuthority.noncePrefix
|
||||
) {
|
||||
throw new TypeError("provider guardian inherited private aliases disagree");
|
||||
}
|
||||
const kind = providerKindFromStem(rawAuthority.stem);
|
||||
const rawLeaf = kind === "vulnerability"
|
||||
? "vulnerability-report.json"
|
||||
: "provenance-attestation.json";
|
||||
return Object.freeze({
|
||||
kind,
|
||||
noncePrefix: rawAuthority.noncePrefix,
|
||||
rawStagingLeaf: rawAuthority.leaf,
|
||||
rawStagingPath: rawAuthority.path,
|
||||
rawPath: `${RAW_DIRECTORY_PATH}/${rawLeaf}`,
|
||||
rawIdentity: rawAuthority.identity,
|
||||
sealedTempLeaf: sealedAuthority.leaf,
|
||||
sealedTempPath: sealedAuthority.path,
|
||||
sealedPath: `${EVIDENCE_DIRECTORY_PATH}/${rawLeaf}`,
|
||||
sealedIdentity: sealedAuthority.identity,
|
||||
});
|
||||
} catch (error) {
|
||||
failures.push(toError(error));
|
||||
return await failBootstrap(rawAuthority, sealedAuthority, failures);
|
||||
}
|
||||
}
|
||||
|
||||
function captureInheritedDirectory(
|
||||
fd: number,
|
||||
canonicalPath: string,
|
||||
label: string,
|
||||
failures: Error[],
|
||||
): boolean {
|
||||
try {
|
||||
assertInheritedDirectory(fd, canonicalPath, label);
|
||||
return true;
|
||||
} catch (error) {
|
||||
failures.push(toError(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function capturePrivateDescriptor(
|
||||
fd: number,
|
||||
label: string,
|
||||
failures: Error[],
|
||||
): Stats | undefined {
|
||||
try {
|
||||
return fstatSync(fd);
|
||||
} catch (error) {
|
||||
failures.push(new Error(`provider guardian inherited ${label} fd is invalid`, {
|
||||
cause: error,
|
||||
}));
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function capturePrivateAlias(
|
||||
input: Readonly<{
|
||||
descriptorMetadata: Stats;
|
||||
descriptorTarget: string;
|
||||
expectedDirectory: string;
|
||||
descriptorDirectory: string;
|
||||
grammar: RegExp;
|
||||
label: string;
|
||||
}>,
|
||||
failures: Error[],
|
||||
): BoundPrivateAuthority | undefined {
|
||||
try {
|
||||
return bindPrivateAlias(input);
|
||||
} catch (error) {
|
||||
failures.push(toError(error));
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function bindPrivateAlias(input: Readonly<{
|
||||
descriptorMetadata: Stats;
|
||||
descriptorTarget: string;
|
||||
expectedDirectory: string;
|
||||
descriptorDirectory: string;
|
||||
grammar: RegExp;
|
||||
label: string;
|
||||
}>): BoundPrivateAuthority {
|
||||
const descriptorTarget = readlinkSync(input.descriptorTarget);
|
||||
if (path.dirname(descriptorTarget) !== input.expectedDirectory) {
|
||||
throw new TypeError(
|
||||
`provider guardian inherited ${input.label} alias is outside its directory`,
|
||||
);
|
||||
}
|
||||
const leaf = path.basename(descriptorTarget);
|
||||
const match = input.grammar.exec(leaf);
|
||||
if (!match) {
|
||||
throw new TypeError(`provider guardian inherited ${input.label} alias is invalid`);
|
||||
}
|
||||
const boundPath = `${input.descriptorDirectory}/${leaf}`;
|
||||
const pathnameMetadata = lstatSync(boundPath);
|
||||
assertPrivateMetadata(input.descriptorMetadata, pathnameMetadata, input.label);
|
||||
return Object.freeze({
|
||||
identity: Object.freeze({
|
||||
dev: input.descriptorMetadata.dev,
|
||||
ino: input.descriptorMetadata.ino,
|
||||
}),
|
||||
leaf,
|
||||
noncePrefix: match[2]!,
|
||||
path: boundPath,
|
||||
stem: match[1]!,
|
||||
});
|
||||
}
|
||||
|
||||
async function failBootstrap(
|
||||
rawAuthority: BoundPrivateAuthority | undefined,
|
||||
sealedAuthority: BoundPrivateAuthority | undefined,
|
||||
failures: Error[],
|
||||
): Promise<never> {
|
||||
for (const authority of [rawAuthority, sealedAuthority]) {
|
||||
if (!authority) continue;
|
||||
await cleanupOwnedPath(authority.path, authority.identity, failures);
|
||||
}
|
||||
closePrivateFds(failures);
|
||||
writeAggregateDiagnostic("provider guardian bootstrap failed", failures);
|
||||
process.exit(126);
|
||||
}
|
||||
|
||||
function assertPrivateMetadata(
|
||||
descriptorMetadata: Stats,
|
||||
pathnameMetadata: Stats,
|
||||
label: string,
|
||||
): void {
|
||||
if (
|
||||
!descriptorMetadata.isFile() || !pathnameMetadata.isFile() ||
|
||||
pathnameMetadata.isSymbolicLink() || descriptorMetadata.dev !== pathnameMetadata.dev ||
|
||||
descriptorMetadata.ino !== pathnameMetadata.ino || descriptorMetadata.nlink !== 1 ||
|
||||
pathnameMetadata.nlink !== 1 || (descriptorMetadata.mode & 0o777) !== 0o600 ||
|
||||
(pathnameMetadata.mode & 0o777) !== 0o600 || descriptorMetadata.size !== 0 ||
|
||||
pathnameMetadata.size !== 0
|
||||
) {
|
||||
throw new TypeError(`provider guardian inherited ${label} identity is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function providerKindFromStem(stem: string): ProviderGuardianKind {
|
||||
if (stem === "vulnerability-report") return "vulnerability";
|
||||
if (stem === "provenance-attestation") return "provenance";
|
||||
throw new TypeError("provider guardian inherited private kind is invalid");
|
||||
}
|
||||
|
||||
function consumeChunk(chunk: Buffer | string): void {
|
||||
if (terminal) return;
|
||||
pending = Buffer.concat([pending, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
|
||||
if (expectedBytes === undefined && pending.byteLength >= 4) {
|
||||
expectedBytes = pending.readUInt32BE(0);
|
||||
if (expectedBytes <= 0 || expectedBytes > MAX_PROVIDER_GUARDIAN_FRAME_PAYLOAD_BYTES) {
|
||||
enqueue(async () => failClosed(126, "provider guardian frame length is invalid"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (expectedBytes !== undefined && pending.byteLength === expectedBytes + 4) {
|
||||
const payload = pending.subarray(4);
|
||||
pending = Buffer.alloc(0);
|
||||
expectedBytes = undefined;
|
||||
enqueue(async () => handleFrame(payload));
|
||||
} else if (expectedBytes !== undefined && pending.byteLength > expectedBytes + 4) {
|
||||
enqueue(async () => failClosed(126, "provider guardian frame has trailing bytes"));
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(operation: () => Promise<void>): void {
|
||||
operations = operations.then(operation).catch(async (error) => {
|
||||
await failClosed(126, error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
}
|
||||
|
||||
async function handleFrame(payload: Buffer): Promise<void> {
|
||||
if (state === "starting") {
|
||||
await establishTransaction(payload);
|
||||
} else if (state === "guarding") {
|
||||
await publishSealedArtifact(payload);
|
||||
} else if (state === "published") {
|
||||
await prepareCommit(payload);
|
||||
} else {
|
||||
await failClosed(126, "provider guardian received data after commit");
|
||||
}
|
||||
}
|
||||
|
||||
async function establishTransaction(payload: Buffer): Promise<void> {
|
||||
const nowEpochMs = Date.now();
|
||||
const guard = decodeProviderGuardianGuard(payload, {
|
||||
nowEpochMs,
|
||||
maxLeaseMs: MAX_PROVIDER_GUARDIAN_LEASE_MS,
|
||||
});
|
||||
if (
|
||||
guard.kind !== bootstrap.kind ||
|
||||
guard.nonce.subarray(0, 16).toString("hex") !== bootstrap.noncePrefix ||
|
||||
providerGuardianRawStagingLeaf(guard.kind, guard.nonce) !== bootstrap.rawStagingLeaf ||
|
||||
providerGuardianSealedTempLeaf(guard.kind, guard.nonce) !== bootstrap.sealedTempLeaf
|
||||
) {
|
||||
throw new TypeError("provider guardian guard does not match inherited private aliases");
|
||||
}
|
||||
assertBoundPrivateLeaf(
|
||||
RAW_STAGING_FD,
|
||||
bootstrap.rawStagingPath,
|
||||
bootstrap.rawIdentity,
|
||||
"raw staging",
|
||||
);
|
||||
assertBoundPrivateLeaf(
|
||||
SEALED_TEMP_FD,
|
||||
bootstrap.sealedTempPath,
|
||||
bootstrap.sealedIdentity,
|
||||
"sealed temp",
|
||||
);
|
||||
transaction = Object.freeze({ guard });
|
||||
|
||||
await link(bootstrap.rawStagingPath, bootstrap.rawPath);
|
||||
assertOwnedPathMetadata(bootstrap.rawStagingPath, bootstrap.rawIdentity, 2, 0o600, 0,
|
||||
"raw staging link");
|
||||
assertOwnedPathMetadata(bootstrap.rawPath, bootstrap.rawIdentity, 2, 0o600, 0,
|
||||
"canonical raw link");
|
||||
await unlink(bootstrap.rawStagingPath);
|
||||
fsyncSync(RAW_DIRECTORY_FD);
|
||||
assertOwnedPathMetadata(bootstrap.rawPath, bootstrap.rawIdentity, 1, 0o600, 0,
|
||||
"canonical raw");
|
||||
assertBoundPrivateLeaf(
|
||||
SEALED_TEMP_FD,
|
||||
bootstrap.sealedTempPath,
|
||||
bootstrap.sealedIdentity,
|
||||
"sealed temp",
|
||||
);
|
||||
|
||||
const remainingLeaseMs = guard.deadlineEpochMs - Date.now();
|
||||
if (remainingLeaseMs <= 0) {
|
||||
throw new TypeError("provider guardian guard deadline expired during startup");
|
||||
}
|
||||
deadline = setTimeout(() => {
|
||||
enqueue(async () => failClosed(124, "provider guardian lease deadline expired", true));
|
||||
}, remainingLeaseMs);
|
||||
writeSync(1, encodeProviderGuardianReady({
|
||||
nonce: guard.nonce,
|
||||
rawDev: bootstrap.rawIdentity.dev,
|
||||
rawIno: bootstrap.rawIdentity.ino,
|
||||
sealedTempLeaf: bootstrap.sealedTempLeaf,
|
||||
sealedDev: bootstrap.sealedIdentity.dev,
|
||||
sealedIno: bootstrap.sealedIdentity.ino,
|
||||
}));
|
||||
state = "guarding";
|
||||
}
|
||||
|
||||
function assertBoundPrivateLeaf(
|
||||
fd: number,
|
||||
target: string,
|
||||
identity: OwnedIdentity,
|
||||
label: string,
|
||||
): void {
|
||||
const descriptorMetadata = fstatSync(fd);
|
||||
const pathnameMetadata = lstatSync(target);
|
||||
assertOwnedMetadata(descriptorMetadata, identity, 1, 0o600, 0, label);
|
||||
assertOwnedMetadata(pathnameMetadata, identity, 1, 0o600, 0, label);
|
||||
if (pathnameMetadata.isSymbolicLink()) {
|
||||
throw new TypeError(`provider guardian ${label} alias became symbolic`);
|
||||
}
|
||||
}
|
||||
|
||||
async function publishSealedArtifact(payload: Buffer): Promise<void> {
|
||||
if (!transaction) {
|
||||
throw new Error("provider guardian transaction identity is unavailable");
|
||||
}
|
||||
const publication = decodeProviderGuardianPublish(payload, transaction.guard.nonce);
|
||||
if (
|
||||
publication.sealedDev !== bootstrap.sealedIdentity.dev ||
|
||||
publication.sealedIno !== bootstrap.sealedIdentity.ino
|
||||
) {
|
||||
throw new TypeError("provider guardian publish identity is invalid");
|
||||
}
|
||||
assertOwnedMetadata(
|
||||
fstatSync(SEALED_TEMP_FD),
|
||||
bootstrap.sealedIdentity,
|
||||
1,
|
||||
0o400,
|
||||
publication.size,
|
||||
"sealed publish descriptor",
|
||||
);
|
||||
const pathnameMetadata = await lstat(bootstrap.sealedTempPath);
|
||||
assertOwnedMetadata(
|
||||
pathnameMetadata,
|
||||
bootstrap.sealedIdentity,
|
||||
1,
|
||||
0o400,
|
||||
publication.size,
|
||||
"sealed publish pathname",
|
||||
);
|
||||
if (pathnameMetadata.isSymbolicLink()) {
|
||||
throw new TypeError("provider guardian sealed publish pathname became symbolic");
|
||||
}
|
||||
const actualSha256 = hashInheritedFile(SEALED_TEMP_FD, publication.size);
|
||||
if (actualSha256 !== publication.sha256) {
|
||||
throw new TypeError("provider guardian publish hash is invalid");
|
||||
}
|
||||
try {
|
||||
await lstat(bootstrap.sealedPath);
|
||||
throw new Error("provider guardian sealed output already exists");
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "ENOENT")) throw error;
|
||||
}
|
||||
await link(bootstrap.sealedTempPath, bootstrap.sealedPath);
|
||||
await unlink(bootstrap.sealedTempPath);
|
||||
fsyncSync(EVIDENCE_DIRECTORY_FD);
|
||||
const finalMetadata = await lstat(bootstrap.sealedPath);
|
||||
assertOwnedMetadata(
|
||||
finalMetadata,
|
||||
bootstrap.sealedIdentity,
|
||||
1,
|
||||
0o400,
|
||||
publication.size,
|
||||
"sealed final",
|
||||
);
|
||||
if (finalMetadata.isSymbolicLink()) {
|
||||
throw new TypeError("provider guardian sealed final became symbolic");
|
||||
}
|
||||
state = "published";
|
||||
writeSync(1, encodeProviderGuardianPublished({
|
||||
nonce: transaction.guard.nonce,
|
||||
sealedDev: bootstrap.sealedIdentity.dev,
|
||||
sealedIno: bootstrap.sealedIdentity.ino,
|
||||
}));
|
||||
}
|
||||
|
||||
function assertOwnedPathMetadata(
|
||||
target: string,
|
||||
identity: OwnedIdentity,
|
||||
expectedLinks: number,
|
||||
expectedMode: number,
|
||||
expectedSize: number,
|
||||
label: string,
|
||||
): void {
|
||||
const metadata = lstatSync(target);
|
||||
assertOwnedMetadata(metadata, identity, expectedLinks, expectedMode, expectedSize, label);
|
||||
if (metadata.isSymbolicLink()) {
|
||||
throw new TypeError(`provider guardian ${label} became symbolic`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertOwnedMetadata(
|
||||
metadata: Stats,
|
||||
identity: OwnedIdentity,
|
||||
expectedLinks: number,
|
||||
expectedMode: number,
|
||||
expectedSize: number,
|
||||
label: string,
|
||||
): void {
|
||||
if (
|
||||
!metadata.isFile() || metadata.dev !== identity.dev || metadata.ino !== identity.ino ||
|
||||
metadata.nlink !== expectedLinks || (metadata.mode & 0o777) !== expectedMode ||
|
||||
metadata.size !== expectedSize
|
||||
) {
|
||||
throw new TypeError(`provider guardian ${label} metadata is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function hashInheritedFile(fd: number, size: number): string {
|
||||
const digest = createHash("sha256");
|
||||
const buffer = Buffer.allocUnsafe(Math.min(65_536, size));
|
||||
let position = 0;
|
||||
while (position < size) {
|
||||
const requested = Math.min(buffer.byteLength, size - position);
|
||||
const bytesRead = readSync(fd, buffer, 0, requested, position);
|
||||
if (bytesRead <= 0) throw new Error("provider guardian sealed publish read was truncated");
|
||||
digest.update(buffer.subarray(0, bytesRead));
|
||||
position += bytesRead;
|
||||
}
|
||||
return digest.digest("hex");
|
||||
}
|
||||
|
||||
async function prepareCommit(payload: Buffer): Promise<void> {
|
||||
if (!transaction) throw new Error("provider guardian transaction identity is unavailable");
|
||||
decodeProviderGuardianCommit(payload, transaction.guard.nonce);
|
||||
const removedRaw = await cleanupOwnedProviderReport({
|
||||
reportPath: bootstrap.rawPath,
|
||||
reportDev: bootstrap.rawIdentity.dev,
|
||||
reportIno: bootstrap.rawIdentity.ino,
|
||||
});
|
||||
if (!removedRaw) throw new Error("provider guardian raw output disappeared before commit");
|
||||
state = "commitPending";
|
||||
}
|
||||
|
||||
async function succeedOnCommittedEof(): Promise<void> {
|
||||
const closeErrors: Error[] = [];
|
||||
closePrivateFds(closeErrors);
|
||||
if (closeErrors.length > 0) {
|
||||
await failClosed(
|
||||
126,
|
||||
"provider guardian private descriptor close failed",
|
||||
false,
|
||||
closeErrors,
|
||||
);
|
||||
return;
|
||||
}
|
||||
terminal = true;
|
||||
if (deadline) clearTimeout(deadline);
|
||||
liveness.removeAllListeners();
|
||||
liveness.destroy();
|
||||
closeControlInputBestEffort();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function failClosed(
|
||||
exitCode: number,
|
||||
message: string,
|
||||
forceSignal = false,
|
||||
priorErrors: readonly Error[] = [],
|
||||
): Promise<void> {
|
||||
if (terminal) return;
|
||||
terminal = true;
|
||||
if (deadline) clearTimeout(deadline);
|
||||
liveness.removeAllListeners();
|
||||
liveness.destroy();
|
||||
const failures = [new Error(message), ...priorErrors];
|
||||
await cleanupOwnedPath(bootstrap.rawStagingPath, bootstrap.rawIdentity, failures);
|
||||
await cleanupOwnedPath(bootstrap.rawPath, bootstrap.rawIdentity, failures);
|
||||
await cleanupOwnedPath(bootstrap.sealedTempPath, bootstrap.sealedIdentity, failures);
|
||||
await cleanupOwnedPath(bootstrap.sealedPath, bootstrap.sealedIdentity, failures);
|
||||
closePrivateFds(failures);
|
||||
closeControlInputBestEffort();
|
||||
writeAggregateDiagnostic("provider guardian failed", failures);
|
||||
if (forceSignal) {
|
||||
try {
|
||||
process.kill(process.pid, "SIGKILL");
|
||||
} finally {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
async function cleanupOwnedPath(
|
||||
target: string,
|
||||
identity: OwnedIdentity,
|
||||
errors: Error[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
await cleanupOwnedProviderReport({
|
||||
reportPath: target,
|
||||
reportDev: identity.dev,
|
||||
reportIno: identity.ino,
|
||||
});
|
||||
} catch (error) {
|
||||
errors.push(toError(error));
|
||||
}
|
||||
}
|
||||
|
||||
function closePrivateFds(errors: Error[]): void {
|
||||
if (privateFdsClosed) return;
|
||||
privateFdsClosed = true;
|
||||
for (const fd of [RAW_STAGING_FD, SEALED_TEMP_FD]) {
|
||||
try {
|
||||
closeSync(fd);
|
||||
} catch (error) {
|
||||
errors.push(toError(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeControlInputBestEffort(): void {
|
||||
try {
|
||||
closeSync(0);
|
||||
} catch {
|
||||
// Terminal cleanup and the exit status must not depend on a diagnostic fd.
|
||||
}
|
||||
}
|
||||
|
||||
function writeAggregateDiagnostic(label: string, failures: readonly Error[]): void {
|
||||
const aggregate = failures.length > 1
|
||||
? new AggregateError(failures, label, { cause: failures[0] })
|
||||
: failures[0];
|
||||
const detail = aggregate instanceof AggregateError
|
||||
? aggregate.errors.map((error) => toError(error).message).join("; ")
|
||||
: aggregate?.message ?? label;
|
||||
try {
|
||||
writeSync(2, `${label}: ${detail}\n`);
|
||||
} catch {
|
||||
// A closed parent-side pipe must not convert fail-closed termination to exit 0.
|
||||
}
|
||||
}
|
||||
|
||||
function assertInheritedDirectory(fd: number, canonicalPath: string, label: string): void {
|
||||
const descriptorMetadata = fstatSync(fd);
|
||||
const pathMetadata = lstatSync(canonicalPath);
|
||||
if (
|
||||
!descriptorMetadata.isDirectory() || !pathMetadata.isDirectory() ||
|
||||
pathMetadata.isSymbolicLink() || descriptorMetadata.dev !== pathMetadata.dev ||
|
||||
descriptorMetadata.ino !== pathMetadata.ino
|
||||
) {
|
||||
throw new TypeError(`provider guardian inherited ${label} fd is not a directory`);
|
||||
}
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { closeSync, createReadStream, writeSync } from "node:fs";
|
||||
|
||||
import { cleanupOwnedProviderReport } from "./provider-raw-cleanup.ts";
|
||||
|
||||
const MAX_FRAME_BYTES = 16_777_216;
|
||||
const reportIdentity = parseReportIdentity(process.argv.slice(2));
|
||||
let pending = Buffer.alloc(0);
|
||||
let expectedBytes: number | undefined;
|
||||
let provider: ReturnType<typeof spawn> | undefined;
|
||||
let providerClosed = false;
|
||||
let livenessLost = false;
|
||||
const liveness = createReadStream("", { fd: 0, autoClose: false });
|
||||
|
||||
liveness.on("data", (chunk: Buffer | string) => {
|
||||
if (provider) {
|
||||
terminateForProtocolFailure("provider scope received trailing protocol bytes");
|
||||
return;
|
||||
}
|
||||
pending = Buffer.concat([pending, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
|
||||
if (expectedBytes === undefined && pending.byteLength >= 4) {
|
||||
expectedBytes = pending.readUInt32BE(0);
|
||||
if (expectedBytes <= 0 || expectedBytes > MAX_FRAME_BYTES) {
|
||||
terminateForProtocolFailure("provider scope frame length is invalid");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (expectedBytes !== undefined && pending.byteLength === expectedBytes + 4) {
|
||||
launchProvider(pending.subarray(4));
|
||||
pending = Buffer.alloc(0);
|
||||
} else if (expectedBytes !== undefined && pending.byteLength > expectedBytes + 4) {
|
||||
terminateForProtocolFailure("provider scope frame has trailing bytes");
|
||||
}
|
||||
});
|
||||
|
||||
liveness.once("end", () => terminateForParentLoss());
|
||||
liveness.once("error", () => terminateForParentLoss());
|
||||
|
||||
function launchProvider(payload: Buffer): void {
|
||||
const frame = parseFrame(payload);
|
||||
if (
|
||||
frame.reportPath !== reportIdentity.reportPath ||
|
||||
frame.reportDev !== reportIdentity.reportDev ||
|
||||
frame.reportIno !== reportIdentity.reportIno
|
||||
) {
|
||||
throw new TypeError("provider scope frame identity does not match its launch identity");
|
||||
}
|
||||
const bwrapInput = Buffer.from(frame.bwrapInputBase64, "base64");
|
||||
provider = spawn("/usr/bin/bwrap", ["--args", "0"], {
|
||||
detached: true,
|
||||
stdio: ["pipe", "inherit", "inherit"],
|
||||
});
|
||||
provider.stdin?.end(bwrapInput);
|
||||
provider.once("error", (error) => finishProvider(frame, null, null, error));
|
||||
provider.once("close", (code, signal) => finishProvider(frame, code, signal));
|
||||
}
|
||||
|
||||
async function finishProvider(
|
||||
frame: ReturnType<typeof parseFrame>,
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
error?: Error,
|
||||
): Promise<void> {
|
||||
if (providerClosed) return;
|
||||
providerClosed = true;
|
||||
if (livenessLost) await cleanupOwnedProviderReport(frame);
|
||||
closeLivenessInput();
|
||||
if (error) {
|
||||
writeSync(2, `${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (signal) process.exit(128 + signalNumber(signal));
|
||||
process.exit(code ?? 1);
|
||||
}
|
||||
|
||||
function terminateForParentLoss(): void {
|
||||
if (livenessLost) return;
|
||||
livenessLost = true;
|
||||
if (!provider || providerClosed) {
|
||||
void cleanupAfterParentLossAndExit();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.kill(-provider.pid!, "SIGKILL");
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "ESRCH")) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupAfterParentLossAndExit(): Promise<void> {
|
||||
try {
|
||||
await cleanupOwnedProviderReport(reportIdentity);
|
||||
} catch (error) {
|
||||
writeSync(2, `${error instanceof Error ? error.message : String(error)}\n`);
|
||||
}
|
||||
closeLivenessInput();
|
||||
process.exit(125);
|
||||
}
|
||||
|
||||
function terminateForProtocolFailure(message: string): void {
|
||||
writeSync(2, `${message}\n`);
|
||||
terminateForParentLoss();
|
||||
}
|
||||
|
||||
function closeLivenessInput(): void {
|
||||
liveness.removeAllListeners();
|
||||
liveness.destroy();
|
||||
try {
|
||||
closeSync(0);
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "EBADF")) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseFrame(payload: Buffer): Readonly<{
|
||||
bwrapInputBase64: string;
|
||||
reportPath: string;
|
||||
reportDev: number;
|
||||
reportIno: number;
|
||||
}> {
|
||||
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(payload)) as Record<string, unknown>;
|
||||
if (
|
||||
typeof value.bwrapInputBase64 !== "string" ||
|
||||
typeof value.reportPath !== "string" || !value.reportPath.startsWith("/") ||
|
||||
!Number.isSafeInteger(value.reportDev) || Number(value.reportDev) <= 0 ||
|
||||
!Number.isSafeInteger(value.reportIno) || Number(value.reportIno) <= 0
|
||||
) {
|
||||
throw new TypeError("provider scope frame payload is invalid");
|
||||
}
|
||||
return {
|
||||
bwrapInputBase64: value.bwrapInputBase64,
|
||||
reportPath: value.reportPath,
|
||||
reportDev: Number(value.reportDev),
|
||||
reportIno: Number(value.reportIno),
|
||||
};
|
||||
}
|
||||
|
||||
function parseReportIdentity(arguments_: readonly string[]): Readonly<{
|
||||
cpuSeconds: number;
|
||||
reportPath: string;
|
||||
reportDev: number;
|
||||
reportIno: number;
|
||||
}> {
|
||||
const [cpuValue, reportPath, devValue, inoValue, ...trailing] = arguments_;
|
||||
const cpuSeconds = Number(cpuValue);
|
||||
const reportDev = Number(devValue);
|
||||
const reportIno = Number(inoValue);
|
||||
if (
|
||||
trailing.length > 0 ||
|
||||
!Number.isSafeInteger(cpuSeconds) || cpuSeconds <= 0 ||
|
||||
typeof reportPath !== "string" || !reportPath.startsWith("/") || reportPath.includes("\0") ||
|
||||
!Number.isSafeInteger(reportDev) || reportDev <= 0 ||
|
||||
!Number.isSafeInteger(reportIno) || reportIno <= 0
|
||||
) {
|
||||
throw new TypeError("provider scope launch identity is invalid");
|
||||
}
|
||||
return { cpuSeconds, reportPath, reportDev, reportIno };
|
||||
}
|
||||
|
||||
function signalNumber(signal: NodeJS.Signals): number {
|
||||
return signal === "SIGKILL" ? 9 : signal === "SIGXCPU" ? 24 : 1;
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export async function superviseProviderEvidence(input: Readonly<{
|
||||
throw new TypeError("provider invocation nonce must contain exactly 32 bytes");
|
||||
}
|
||||
const invocationNonce = nonceBytes.toString("hex");
|
||||
const now = (dependencies.nowEpochMs ?? Date.now)();
|
||||
const nowEpochMs = dependencies.nowEpochMs ?? Date.now;
|
||||
const result = await (dependencies.withVerifiedCandidate ?? withVerifiedCapturedCandidate)({
|
||||
captured,
|
||||
verify: async ({ extractionRoot, manifest }) => {
|
||||
@@ -71,13 +71,22 @@ export async function superviseProviderEvidence(input: Readonly<{
|
||||
distSha256: manifest.distSha256,
|
||||
lockfileSha256: manifest.lockfileSha256,
|
||||
}),
|
||||
secretScanAttestation: Object.freeze({
|
||||
status: "PASS" as const,
|
||||
localEvidenceAssessmentSha256: local.identity.assessmentSha256,
|
||||
sourceSetSha256: local.identity.sourceSetSha256,
|
||||
policySha256: local.identity.secretScan.policySha256,
|
||||
sarifSha256: local.identity.secretScan.sarifSha256,
|
||||
scanInputSha256: local.identity.secretScan.scanInputSha256,
|
||||
}),
|
||||
vulnerabilityInvocationNonce:
|
||||
input.kind === "vulnerability" ? invocationNonce : "0".repeat(64),
|
||||
provenanceInvocationNonce:
|
||||
input.kind === "provenance" ? invocationNonce : "0".repeat(64),
|
||||
});
|
||||
const issuedAt = new Date(now).toISOString();
|
||||
const expiresAt = new Date(now + 60 * 60 * 1_000).toISOString();
|
||||
const issuedNow = nowEpochMs();
|
||||
const issuedAt = new Date(issuedNow).toISOString();
|
||||
const expiresAt = new Date(issuedNow + 60 * 60 * 1_000).toISOString();
|
||||
await input.executeProvider({
|
||||
candidateRoot: extractionRoot,
|
||||
environment: providerInvocationEnvironment({
|
||||
@@ -98,7 +107,7 @@ export async function superviseProviderEvidence(input: Readonly<{
|
||||
capturedReport,
|
||||
expectedContext,
|
||||
trust: input.trust,
|
||||
nowEpochMs: () => now,
|
||||
nowEpochMs,
|
||||
});
|
||||
return Object.freeze({ evidence, invocationNonce, expectedContext });
|
||||
},
|
||||
@@ -135,6 +144,17 @@ export function providerInvocationEnvironment(input: Readonly<{
|
||||
CANDIDATE_BUNDLE_SHA256: input.expectedContext.candidate.bundleSha256,
|
||||
CANDIDATE_DIST_SHA256: input.expectedContext.candidate.distSha256,
|
||||
CANDIDATE_LOCKFILE_SHA256: input.expectedContext.candidate.lockfileSha256,
|
||||
SECRET_SCAN_STATUS: input.expectedContext.secretScanAttestation.status,
|
||||
SECRET_SCAN_LOCAL_EVIDENCE_ASSESSMENT_SHA256:
|
||||
input.expectedContext.secretScanAttestation.localEvidenceAssessmentSha256,
|
||||
SECRET_SCAN_SOURCE_SET_SHA256:
|
||||
input.expectedContext.secretScanAttestation.sourceSetSha256,
|
||||
SECRET_SCAN_POLICY_SHA256:
|
||||
input.expectedContext.secretScanAttestation.policySha256,
|
||||
SECRET_SCAN_SARIF_SHA256:
|
||||
input.expectedContext.secretScanAttestation.sarifSha256,
|
||||
SECRET_SCAN_INPUT_SHA256:
|
||||
input.expectedContext.secretScanAttestation.scanInputSha256,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createPublicKey } from "node:crypto";
|
||||
import path from "node:path";
|
||||
|
||||
import { readBoundedRegularFile } from "./ci-artifact-validator.ts";
|
||||
import {
|
||||
providerPublicKeyFingerprint,
|
||||
type ProviderTrust,
|
||||
} from "./provider-evidence.ts";
|
||||
|
||||
export async function readProviderTrust(
|
||||
configuredRoot: string,
|
||||
publicKeyPath: string | undefined,
|
||||
keyId: string | undefined,
|
||||
): Promise<ProviderTrust | null> {
|
||||
if (!publicKeyPath || !keyId?.trim()) return null;
|
||||
try {
|
||||
const root = path.resolve(configuredRoot);
|
||||
const absolute = path.resolve(root, publicKeyPath);
|
||||
const relative = path.relative(root, absolute);
|
||||
const outside =
|
||||
relative === ".." ||
|
||||
relative.startsWith(`..${path.sep}`) ||
|
||||
path.isAbsolute(relative);
|
||||
const bytes = await readBoundedRegularFile({
|
||||
root: outside ? path.dirname(absolute) : root,
|
||||
relativePath: outside
|
||||
? path.basename(absolute)
|
||||
: relative.replaceAll(path.sep, "/"),
|
||||
maxBytes: 1_048_576,
|
||||
});
|
||||
const publicKey = createPublicKey(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
||||
);
|
||||
return Object.freeze({
|
||||
keyId,
|
||||
publicKey,
|
||||
publicKeyFingerprint: providerPublicKeyFingerprint(publicKey),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,42 @@ export const RELEASE_CANDIDATE_MANIFEST_PATH =
|
||||
export const LOCAL_EVIDENCE_ASSESSMENT_PATH =
|
||||
"artifacts/security/local-evidence-assessment.json";
|
||||
|
||||
export const LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS = Object.freeze([
|
||||
"scripts/contracts/release-artifacts.ts",
|
||||
"scripts/create-release-candidate.ts",
|
||||
"scripts/generate-supply-chain.ts",
|
||||
"scripts/lib/build-manifest-outputs.ts",
|
||||
"scripts/lib/json-schema.ts",
|
||||
"scripts/lib/local-policy-evidence.ts",
|
||||
"scripts/lib/local-release-evidence.ts",
|
||||
"scripts/lib/release-candidate.ts",
|
||||
"scripts/lib/release-input-evidence.ts",
|
||||
"scripts/lib/release-runtime-coherence.ts",
|
||||
"scripts/lib/repository-file-inventory.ts",
|
||||
"scripts/lib/secret-scan-evaluator.ts",
|
||||
"scripts/lib/secret-scan-policy.ts",
|
||||
"scripts/lib/secret-scan.ts",
|
||||
"scripts/lib/supply-chain.ts",
|
||||
"scripts/lib/validated-json-artifact.ts",
|
||||
"src/contracts/release-artifacts.ts",
|
||||
"src/features/installed-contract-contributions.ts",
|
||||
"src/features/installed-feature-contracts.ts",
|
||||
] as const);
|
||||
|
||||
export const LOCAL_EVIDENCE_POLICY_INPUT_PATHS = Object.freeze([
|
||||
"config/security/dependency-baseline.approval.json",
|
||||
"config/security/dependency-baseline.json",
|
||||
"config/security/dependency-change-evidence.json",
|
||||
"config/security/dependency-policy.json",
|
||||
"config/security/secret-scan-policy.json",
|
||||
"config/security/vulnerability-exceptions.json",
|
||||
"config/security/vulnerability-policy.json",
|
||||
"schemas/artifacts/build-manifest.schema.json",
|
||||
"schemas/artifacts/dependency-inventory.schema.json",
|
||||
"schemas/artifacts/supply-chain-verification.schema.json",
|
||||
...LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS,
|
||||
] as const);
|
||||
|
||||
export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([
|
||||
"pnpm-lock.yaml",
|
||||
"artifacts/performance/bundle.json",
|
||||
@@ -52,6 +88,7 @@ export const RELEASE_CANDIDATE_EVIDENCE_PATHS = Object.freeze([
|
||||
"artifacts/security/supply-chain-coherence.json",
|
||||
"artifacts/security/supply-chain-verification.json",
|
||||
"artifacts/security/vulnerability-report.json",
|
||||
...LOCAL_EVIDENCE_POLICY_INPUT_PATHS,
|
||||
]);
|
||||
|
||||
export type DistOutput = Readonly<{
|
||||
|
||||
@@ -231,7 +231,7 @@ export async function pruneRemovalFixtureCiContract(options: Readonly<{
|
||||
contract.artifactSchemas = contract.artifactSchemas.filter(({ id }) =>
|
||||
referencedSchemaIds.has(id)
|
||||
);
|
||||
const validated = parseCiGateContract(contract);
|
||||
const validated = parseCiGateContract(contract, { mode: "removal-fixture" });
|
||||
await Promise.all([
|
||||
writeFile(packagePath, `${JSON.stringify(packageDocument, null, 2)}\n`),
|
||||
writeFile(gatesPath, `${JSON.stringify(validated, null, 2)}\n`),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -175,6 +176,7 @@ export async function evaluateSecretScan(input: Readonly<{
|
||||
const excluded = new Set(
|
||||
input.policy.excludedPaths.map((entry) => entry.replaceAll("\\", "/")),
|
||||
);
|
||||
const scanInputs: Readonly<{ path: string; bytes: number; sha256: string }>[] = [];
|
||||
for (const scanFile of [...new Set(scanFiles)].sort()) {
|
||||
const normalized = scanFile.replaceAll("\\", "/");
|
||||
if (
|
||||
@@ -186,8 +188,15 @@ export async function evaluateSecretScan(input: Readonly<{
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const content = await input.readText(scanFile);
|
||||
const bytes = Buffer.from(content, "utf8");
|
||||
scanInputs.push(Object.freeze({
|
||||
path: normalized,
|
||||
bytes: bytes.byteLength,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
}));
|
||||
findings.push(
|
||||
...findSecretMatches(normalized, await input.readText(scanFile), {
|
||||
...findSecretMatches(normalized, content, {
|
||||
allowlist: input.policy.allowlist,
|
||||
now,
|
||||
}),
|
||||
@@ -235,6 +244,8 @@ export async function evaluateSecretScan(input: Readonly<{
|
||||
findings: Object.freeze(findings),
|
||||
policyFailures: Object.freeze(policyFailures),
|
||||
scanFiles: Object.freeze([...scanFiles]),
|
||||
scanInputs: Object.freeze(scanInputs),
|
||||
scanInputSha256: supplyChainDigest(scanInputs),
|
||||
sarif,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { appendFile } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
cleanupFinalizedPromotion,
|
||||
finalizeVerifiedPromotion,
|
||||
} from "./promotion-stager.ts";
|
||||
|
||||
export async function runStageVerifiedPromotionCli(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
dependencies: Readonly<{
|
||||
cwd?: () => string;
|
||||
finalize?: typeof finalizeVerifiedPromotion;
|
||||
cleanup?: typeof cleanupFinalizedPromotion;
|
||||
appendOutput?: (path: string, content: string) => Promise<void>;
|
||||
writeStdout?: (content: string) => void;
|
||||
}> = {},
|
||||
): Promise<void> {
|
||||
const required = (name: string): string => {
|
||||
const value = environment[name];
|
||||
if (!value) throw new TypeError(`promotion staging environment is missing ${name}`);
|
||||
return value;
|
||||
};
|
||||
const attempt = Number(
|
||||
environment.GITEA_RUN_ATTEMPT ??
|
||||
environment.GITHUB_RUN_ATTEMPT ??
|
||||
required("CI_RUN_ATTEMPT"),
|
||||
);
|
||||
if (!Number.isInteger(attempt) || attempt < 1 || attempt > 1_000) {
|
||||
throw new TypeError("promotion staging run attempt is invalid");
|
||||
}
|
||||
const runnerTempRoot = required("RUNNER_TEMP");
|
||||
const staged = await (dependencies.finalize ?? finalizeVerifiedPromotion)({
|
||||
repositoryRoot: (dependencies.cwd ?? process.cwd)(),
|
||||
archivePath: required("CANDIDATE_ARCHIVE_PATH"),
|
||||
expectedArchiveSha256: required("CANDIDATE_ARCHIVE_SHA256"),
|
||||
vulnerabilityReportPath: required("VULNERABILITY_REPORT_PATH"),
|
||||
provenanceAttestationPath: required("PROVENANCE_ATTESTATION_PATH"),
|
||||
vulnerabilityPublicKeyPath: required("VULNERABILITY_PUBLIC_KEY_PATH"),
|
||||
vulnerabilityKeyId: required("VULNERABILITY_KEY_ID"),
|
||||
provenancePublicKeyPath: required("PROVENANCE_PUBLIC_KEY_PATH"),
|
||||
provenanceKeyId: required("PROVENANCE_KEY_ID"),
|
||||
expectedRun: {
|
||||
id:
|
||||
environment.GITEA_RUN_ID ??
|
||||
environment.GITHUB_RUN_ID ??
|
||||
required("CI_RUN_ID"),
|
||||
attempt,
|
||||
sourceRevision:
|
||||
environment.EXPECTED_SOURCE_REVISION ?? required("VITE_COMMIT_SHA"),
|
||||
},
|
||||
vulnerabilityInvocationNonce: required("VULNERABILITY_INVOCATION_NONCE"),
|
||||
provenanceInvocationNonce: required("PROVENANCE_INVOCATION_NONCE"),
|
||||
runnerTempRoot,
|
||||
});
|
||||
try {
|
||||
const output = required("GITHUB_OUTPUT");
|
||||
const content = [
|
||||
`staging_root=${staged.stagingRoot}`,
|
||||
`cleanup_token=${staged.cleanupToken}`,
|
||||
`runner_temp_dev=${staged.runnerTempIdentity.dev}`,
|
||||
`runner_temp_ino=${staged.runnerTempIdentity.ino}`,
|
||||
`staging_dev=${staged.stagingIdentity.dev}`,
|
||||
`staging_ino=${staged.stagingIdentity.ino}`,
|
||||
"",
|
||||
].join("\n");
|
||||
await (dependencies.appendOutput ?? defaultAppendOutput)(output, content);
|
||||
} catch (error) {
|
||||
try {
|
||||
await (dependencies.cleanup ?? cleanupFinalizedPromotion)({
|
||||
runnerTempRoot,
|
||||
stagingRoot: staged.stagingRoot,
|
||||
cleanupToken: staged.cleanupToken,
|
||||
runnerTempIdentity: staged.runnerTempIdentity,
|
||||
stagingIdentity: staged.stagingIdentity,
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError(
|
||||
[error, cleanupError],
|
||||
"promotion output publication and direct staging cleanup both failed",
|
||||
{ cause: cleanupError },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
(dependencies.writeStdout ?? process.stdout.write.bind(process.stdout))(
|
||||
`Promotion staging: ${staged.files
|
||||
.map(({ name, sha256 }) => `${name}=${sha256}`)
|
||||
.join(", ")} PASS\n`,
|
||||
);
|
||||
}
|
||||
|
||||
async function defaultAppendOutput(path: string, content: string): Promise<void> {
|
||||
await appendFile(path, content, { encoding: "utf8" });
|
||||
}
|
||||
@@ -15,6 +15,17 @@ export type ValidatedJsonArtifactInput = Readonly<{
|
||||
value: unknown;
|
||||
}>;
|
||||
|
||||
export function serializeValidatedJsonArtifact(
|
||||
input: ValidatedJsonArtifactInput,
|
||||
): Buffer {
|
||||
const parsed = input.schema.parse(input.value);
|
||||
const serialized = JSON.stringify(parsed, null, 2);
|
||||
if (serialized === undefined) {
|
||||
throw new TypeError("Validated JSON artifact is not serializable");
|
||||
}
|
||||
return Buffer.from(`${serialized}\n`, "utf8");
|
||||
}
|
||||
|
||||
export type ValidatedJsonArtifactFileSystem = Readonly<{
|
||||
open: (path: string, flags: number, mode: number) => Promise<{
|
||||
writeFile(data: string, encoding: "utf8"): Promise<unknown>;
|
||||
@@ -64,11 +75,7 @@ export function createValidatedJsonArtifactWriter(
|
||||
return async function writeArtifact(
|
||||
input: ValidatedJsonArtifactInput,
|
||||
): Promise<void> {
|
||||
const parsed = input.schema.parse(input.value);
|
||||
const serialized = JSON.stringify(parsed, null, 2);
|
||||
if (serialized === undefined) {
|
||||
throw new TypeError("Validated JSON artifact is not serializable");
|
||||
}
|
||||
const serialized = serializeValidatedJsonArtifact(input);
|
||||
|
||||
const temporaryPath = path.join(
|
||||
path.dirname(input.path),
|
||||
@@ -88,7 +95,7 @@ export function createValidatedJsonArtifactWriter(
|
||||
let writeFailed = false;
|
||||
let writeFailure: unknown;
|
||||
try {
|
||||
await handle.writeFile(`${serialized}\n`, "utf8");
|
||||
await handle.writeFile(serialized.toString("utf8"), "utf8");
|
||||
await handle.sync();
|
||||
} catch (error) {
|
||||
writeFailed = true;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { access, appendFile, lstat, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { access, appendFile, lstat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
@@ -9,15 +9,40 @@ import {
|
||||
vulnerabilityProviderReportSchema,
|
||||
} from "./lib/provider-evidence.ts";
|
||||
import { readBoundedRegularFile } from "./lib/ci-artifact-validator.ts";
|
||||
import { readProviderTrust } from "./lib/promotion-verifier.ts";
|
||||
import { readProviderTrust } from "./lib/provider-trust.ts";
|
||||
import { superviseProviderEvidence } from "./lib/provider-supervisor.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
import { serializeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
import {
|
||||
assertSafePublishLeaf,
|
||||
ensureSafePublishDirectory,
|
||||
} from "./lib/ci-gate-log.ts";
|
||||
import {
|
||||
encodeProviderBwrapInput,
|
||||
encodeProviderScopeFrame,
|
||||
formatProviderCgroupUnitName,
|
||||
systemctlKillProviderArguments,
|
||||
systemdRunProviderArguments,
|
||||
} from "./lib/provider-cgroup.ts";
|
||||
import {
|
||||
assertProviderGuardianLeasePaths,
|
||||
createProviderScopeGuardianLatch,
|
||||
startProviderGuardian,
|
||||
type ProviderGuardianLease,
|
||||
} from "./lib/provider-guardian-client.ts";
|
||||
import { createProviderOutputLimiter } from "./lib/provider-output-limiter.ts";
|
||||
|
||||
const kind = process.argv[process.argv.indexOf("--kind") + 1];
|
||||
const PROVIDER_TMP_BYTES = 16_777_216;
|
||||
const PROVIDER_MASK_BYTES = 1_048_576;
|
||||
const PROVIDER_MAX_OUTPUT_BYTES = 1_048_576;
|
||||
const DEFAULT_PROVIDER_CPU_SECONDS = 1_200;
|
||||
const DEFAULT_PROVIDER_TIMEOUT_MS = 30 * 60 * 1_000;
|
||||
const PROVIDER_POSTPROCESS_TIMEOUT_MS = 10 * 60 * 1_000;
|
||||
const PROVIDER_REAP_TIMEOUT_MS = 5_000;
|
||||
const PROVIDER_CONTROL_TIMEOUT_MS = 5_000;
|
||||
const PROVIDER_CONTROL_MAX_OUTPUT_BYTES = 65_536;
|
||||
const PROVIDER_CONTROL_POLL_MS = 25;
|
||||
const MINIMUM_PROVIDER_SYSTEMD_VERSION = 254;
|
||||
if (kind !== "vulnerability" && kind !== "provenance") {
|
||||
process.stderr.write("Usage: run-and-validate-provider --kind vulnerability|provenance\n");
|
||||
process.exit(2);
|
||||
@@ -67,7 +92,12 @@ const workspaceRoot = process.cwd();
|
||||
const reportAbsolute = path.resolve(reportPath);
|
||||
const rawDirectory = path.dirname(reportAbsolute);
|
||||
const sealedAbsolute = path.resolve(sealedPath);
|
||||
const expectedRawLeaf = kind === "vulnerability"
|
||||
? "vulnerability-report.json"
|
||||
: "provenance-attestation.json";
|
||||
const expectedRawDirectory = path.resolve(workspaceRoot, "provider-evidence/untrusted");
|
||||
if (
|
||||
reportAbsolute !== path.join(expectedRawDirectory, expectedRawLeaf) ||
|
||||
path.basename(rawDirectory) !== "untrusted" ||
|
||||
path.dirname(rawDirectory) !== path.dirname(sealedAbsolute) ||
|
||||
reportAbsolute === sealedAbsolute
|
||||
@@ -79,46 +109,98 @@ await prepareMissingProviderOutput(workspaceRoot, sealedAbsolute, sealedPath, "s
|
||||
await access("/usr/bin/bwrap", constants.X_OK).catch(() => {
|
||||
throw new Error("provider sandbox unavailable: /usr/bin/bwrap is required");
|
||||
});
|
||||
await access("/usr/bin/prlimit", constants.X_OK).catch(() => {
|
||||
throw new Error("provider sandbox unavailable: /usr/bin/prlimit is required");
|
||||
});
|
||||
await access("/usr/bin/systemd-run", constants.X_OK).catch(() => {
|
||||
throw new Error("provider cgroup unavailable: /usr/bin/systemd-run is required");
|
||||
});
|
||||
await access("/usr/bin/systemctl", constants.X_OK).catch(() => {
|
||||
throw new Error("provider cgroup unavailable: /usr/bin/systemctl is required");
|
||||
});
|
||||
await assertProviderCgroupManagerAvailable();
|
||||
const trust = await readProviderTrust(workspaceRoot, publicKeyPath, keyId);
|
||||
if (!trust) throw new TypeError("provider supervisor trust key is invalid");
|
||||
const supervised = await superviseProviderEvidence({
|
||||
kind,
|
||||
archivePath,
|
||||
expectedArchiveSha256: archiveSha256,
|
||||
expectedRun: { id: runId, attempt: runAttempt, sourceRevision },
|
||||
trust,
|
||||
executeProvider: async ({ candidateRoot, environment }) => {
|
||||
const childEnvironment = createProviderEnvironment(kind, reportPath, environment);
|
||||
await runProviderInSandbox(
|
||||
command,
|
||||
childEnvironment,
|
||||
rawDirectory,
|
||||
workspaceRoot,
|
||||
candidateRoot,
|
||||
let guardianLease: ProviderGuardianLease | undefined;
|
||||
let guardianTerminalStarted = false;
|
||||
try {
|
||||
const providerWallTimeoutMs = providerTimeoutMs();
|
||||
const supervised = await superviseProviderEvidence({
|
||||
kind,
|
||||
archivePath,
|
||||
expectedArchiveSha256: archiveSha256,
|
||||
expectedRun: { id: runId, attempt: runAttempt, sourceRevision },
|
||||
trust,
|
||||
executeProvider: async ({ candidateRoot, environment }) => {
|
||||
guardianLease = await startProviderGuardian({
|
||||
kind,
|
||||
workspaceRoot,
|
||||
leaseMs: providerWallTimeoutMs + PROVIDER_POSTPROCESS_TIMEOUT_MS,
|
||||
guardianScript: path.join(workspaceRoot, "scripts/lib/provider-raw-guardian.ts"),
|
||||
});
|
||||
const activeGuardian = guardianLease;
|
||||
assertProviderGuardianLeasePaths(activeGuardian, {
|
||||
rawPath: reportAbsolute,
|
||||
sealedPath: sealedAbsolute,
|
||||
});
|
||||
const childEnvironment = createProviderEnvironment(kind, reportPath, environment);
|
||||
await runProviderInSandbox(
|
||||
kind,
|
||||
command,
|
||||
childEnvironment,
|
||||
activeGuardian.rawPath,
|
||||
activeGuardian.rawIdentity,
|
||||
workspaceRoot,
|
||||
candidateRoot,
|
||||
providerWallTimeoutMs,
|
||||
activeGuardian.prematureExit,
|
||||
);
|
||||
await assertOwnedProviderOutput(activeGuardian.rawPath, activeGuardian.rawIdentity);
|
||||
},
|
||||
captureReport: async () => {
|
||||
if (!guardianLease) throw new Error("provider raw guardian lease was not established");
|
||||
await assertOwnedProviderOutput(guardianLease.rawPath, guardianLease.rawIdentity);
|
||||
return readBoundedRegularFile({
|
||||
root: workspaceRoot,
|
||||
relativePath: path.relative(workspaceRoot, guardianLease.rawPath).replaceAll(path.sep, "/"),
|
||||
maxBytes: 8_388_608,
|
||||
});
|
||||
},
|
||||
});
|
||||
if (!guardianLease) throw new Error("provider raw guardian lease was not established");
|
||||
await assertSafePublishLeaf(sealedAbsolute, sealedPath);
|
||||
await guardianLease.publish(serializeValidatedJsonArtifact({
|
||||
path: sealedPath,
|
||||
schema:
|
||||
kind === "vulnerability"
|
||||
? vulnerabilityProviderReportSchema
|
||||
: provenanceProviderAttestationSchema,
|
||||
value: supervised.evidence,
|
||||
}));
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
await appendFile(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
`invocation_nonce=${supervised.invocationNonce}\n`,
|
||||
"utf8",
|
||||
);
|
||||
},
|
||||
captureReport: () =>
|
||||
readBoundedRegularFile({
|
||||
root: workspaceRoot,
|
||||
relativePath: path.relative(workspaceRoot, reportAbsolute).replaceAll(path.sep, "/"),
|
||||
maxBytes: 8_388_608,
|
||||
}),
|
||||
});
|
||||
await assertSafePublishLeaf(sealedAbsolute, sealedPath);
|
||||
await writeValidatedJsonArtifact({
|
||||
path: sealedPath,
|
||||
schema:
|
||||
kind === "vulnerability"
|
||||
? vulnerabilityProviderReportSchema
|
||||
: provenanceProviderAttestationSchema,
|
||||
value: supervised.evidence,
|
||||
});
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
await appendFile(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
`invocation_nonce=${supervised.invocationNonce}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
guardianTerminalStarted = true;
|
||||
await guardianLease.commit();
|
||||
} catch (providerError) {
|
||||
const failures = [toError(providerError)];
|
||||
if (guardianLease && !guardianTerminalStarted) {
|
||||
try {
|
||||
await guardianLease.abort();
|
||||
} catch (guardianError) {
|
||||
failures.push(toError(guardianError));
|
||||
}
|
||||
}
|
||||
if (failures.length > 1) {
|
||||
throw new AggregateError(failures, "provider lifecycle and owned output cleanup failed", {
|
||||
cause: providerError,
|
||||
});
|
||||
}
|
||||
throw providerError;
|
||||
}
|
||||
process.stdout.write(`${kind} provider supervised validation: PASS\n`);
|
||||
|
||||
@@ -139,7 +221,7 @@ function createProviderEnvironment(
|
||||
? { VULNERABILITY_REPORT_PATH: rawReportPath }
|
||||
: { PROVENANCE_ATTESTATION_PATH: rawReportPath }),
|
||||
};
|
||||
for (const name of ["LANG", "LC_ALL", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"] as const) {
|
||||
for (const name of ["LANG", "LC_ALL"] as const) {
|
||||
if (process.env[name]) environment[name] = process.env[name];
|
||||
}
|
||||
const credentialPrefix = `${providerKind.toUpperCase()}_PROVIDER_`;
|
||||
@@ -152,85 +234,312 @@ function createProviderEnvironment(
|
||||
}
|
||||
|
||||
async function runProviderInSandbox(
|
||||
providerKind: "vulnerability" | "provenance",
|
||||
command: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
rawDirectory: string,
|
||||
reportAbsolute: string,
|
||||
reportIdentity: Readonly<{ dev: number; ino: number }>,
|
||||
workspaceRoot: string,
|
||||
candidateRoot: string,
|
||||
timeoutMs: number,
|
||||
guardianExit: Promise<Error>,
|
||||
): Promise<void> {
|
||||
const scratch = await mkdtemp(path.join(tmpdir(), "ci-provider-sandbox-"));
|
||||
try {
|
||||
await mkdir(path.join(scratch, "provider-home"));
|
||||
await writeFile(path.join(scratch, "node"), "", { mode: 0o500 });
|
||||
const arguments_ = [
|
||||
"--die-with-parent",
|
||||
"--new-session",
|
||||
"--as-pid-1",
|
||||
"--unshare-pid",
|
||||
"--unshare-ipc",
|
||||
"--unshare-uts",
|
||||
"--dev", "/dev",
|
||||
"--proc", "/proc",
|
||||
"--bind", scratch, "/tmp",
|
||||
"--dir", "/etc",
|
||||
];
|
||||
for (const source of ["/usr", "/bin", "/lib", "/lib64"]) {
|
||||
if (await exists(source)) arguments_.push("--ro-bind", source, source);
|
||||
}
|
||||
// setup-node commonly installs outside /usr. Expose only the exact trusted
|
||||
// runtime binary, never its credential-bearing user/toolcache directory.
|
||||
arguments_.push("--ro-bind", process.execPath, "/tmp/node");
|
||||
for (const source of [
|
||||
"/etc/ca-certificates",
|
||||
"/etc/ssl",
|
||||
"/etc/resolv.conf",
|
||||
"/etc/hosts",
|
||||
"/etc/nsswitch.conf",
|
||||
"/etc/passwd",
|
||||
"/etc/group",
|
||||
]) {
|
||||
if (await exists(source)) arguments_.push("--ro-bind", source, source);
|
||||
}
|
||||
for (const directory of missingDestinationAncestors(workspaceRoot)) {
|
||||
arguments_.push("--dir", directory);
|
||||
}
|
||||
arguments_.push(
|
||||
"--ro-bind", workspaceRoot, workspaceRoot,
|
||||
);
|
||||
if (await exists(path.join(workspaceRoot, ".git"))) {
|
||||
arguments_.push("--tmpfs", path.join(workspaceRoot, ".git"));
|
||||
}
|
||||
arguments_.push(
|
||||
"--bind", rawDirectory, rawDirectory,
|
||||
"--ro-bind", candidateRoot, "/candidate",
|
||||
"--chdir", workspaceRoot,
|
||||
"/bin/sh", "-eu", "-c", command,
|
||||
);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("/usr/bin/bwrap", arguments_, {
|
||||
env: { ...environment, PATH: `/tmp:${environment.PATH ?? ""}` },
|
||||
stdio: "inherit",
|
||||
});
|
||||
let settled = false;
|
||||
const finish = (error?: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
error ? reject(error) : resolve();
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
finish(new Error("sandboxed external provider command timed out"));
|
||||
}, 30 * 60 * 1_000);
|
||||
child.once("error", (error) => finish(error));
|
||||
child.once("close", (code, signal) => {
|
||||
if (code === 0 && signal === null) finish();
|
||||
else finish(new Error(`sandboxed external provider failed: exit=${code ?? "none"}, signal=${signal ?? "none"}`));
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await rm(scratch, { recursive: true, force: true });
|
||||
const cpuSeconds = providerCpuSeconds();
|
||||
const bwrapArguments = [
|
||||
"--die-with-parent", "--new-session", "--as-pid-1",
|
||||
"--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-net",
|
||||
"--dev", "/dev", "--remount-ro", "/dev",
|
||||
"--proc", "/proc", "--remount-ro", "/proc",
|
||||
"--size", String(PROVIDER_TMP_BYTES), "--tmpfs", "/tmp",
|
||||
"--dir", "/tmp/provider-home",
|
||||
"--size", String(PROVIDER_MASK_BYTES), "--tmpfs", "/etc",
|
||||
];
|
||||
for (const source of ["/usr", "/bin", "/lib", "/lib64"]) {
|
||||
if (await exists(source)) bwrapArguments.push("--ro-bind", source, source);
|
||||
}
|
||||
bwrapArguments.push("--ro-bind", process.execPath, "/tmp/node");
|
||||
for (const source of [
|
||||
"/etc/ca-certificates", "/etc/ssl", "/etc/nsswitch.conf", "/etc/passwd", "/etc/group",
|
||||
]) {
|
||||
if (await exists(source)) bwrapArguments.push("--ro-bind", source, source);
|
||||
}
|
||||
bwrapArguments.push("--remount-ro", "/etc");
|
||||
for (const directory of missingDestinationAncestors(workspaceRoot)) {
|
||||
bwrapArguments.push("--dir", directory);
|
||||
}
|
||||
bwrapArguments.push("--ro-bind", workspaceRoot, workspaceRoot);
|
||||
if (await exists(path.join(workspaceRoot, ".git"))) {
|
||||
bwrapArguments.push(
|
||||
"--size", String(PROVIDER_MASK_BYTES),
|
||||
"--tmpfs", path.join(workspaceRoot, ".git"),
|
||||
"--remount-ro", path.join(workspaceRoot, ".git"),
|
||||
);
|
||||
}
|
||||
bwrapArguments.push(
|
||||
"--ro-bind", candidateRoot, "/candidate",
|
||||
"--remount-ro", "/tmp",
|
||||
"--remount-ro", "/",
|
||||
"--bind", reportAbsolute, reportAbsolute,
|
||||
"--chdir", workspaceRoot,
|
||||
"--", "/usr/bin/prlimit",
|
||||
"--core=0:0",
|
||||
"--fsize=8388607:8388607",
|
||||
"--nofile=64:64",
|
||||
`--cpu=${cpuSeconds}:${cpuSeconds}`,
|
||||
"--", "/bin/sh", "-eu", "-c",
|
||||
'exec /bin/sh -eu -c "$PROVIDER_COMMAND"',
|
||||
);
|
||||
const unitName = formatProviderCgroupUnitName(
|
||||
providerKind,
|
||||
process.pid,
|
||||
randomBytes(12).toString("hex"),
|
||||
);
|
||||
const bwrapInput = encodeProviderBwrapInput(bwrapArguments, {
|
||||
...environment,
|
||||
PATH: `/tmp:${environment.PATH ?? ""}`,
|
||||
PROVIDER_COMMAND: command,
|
||||
});
|
||||
const scopeFrame = encodeProviderScopeFrame({
|
||||
bwrapInput,
|
||||
reportPath: reportAbsolute,
|
||||
reportDev: reportIdentity.dev,
|
||||
reportIno: reportIdentity.ino,
|
||||
});
|
||||
await waitForProvider(
|
||||
spawn("/usr/bin/systemd-run", systemdRunProviderArguments(
|
||||
unitName,
|
||||
timeoutMs,
|
||||
cpuSeconds,
|
||||
process.execPath,
|
||||
path.join(workspaceRoot, "scripts/lib/provider-scope-wrapper.ts"),
|
||||
reportAbsolute,
|
||||
reportIdentity.dev,
|
||||
reportIdentity.ino,
|
||||
), {
|
||||
env: providerCgroupClientEnvironment(),
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}),
|
||||
timeoutMs,
|
||||
unitName,
|
||||
scopeFrame,
|
||||
guardianExit,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForProvider(
|
||||
child: ReturnType<typeof spawn>,
|
||||
timeoutMs: number,
|
||||
unitName: string,
|
||||
scopeFrame: Buffer,
|
||||
guardianExit: Promise<Error>,
|
||||
): Promise<void> {
|
||||
let termination: "guardian" | "timeout" | "output" | undefined;
|
||||
let guardianError: Error | undefined;
|
||||
let signalTermination!: () => void;
|
||||
let kill: Promise<void> | undefined;
|
||||
const terminationStarted = new Promise<void>((resolve) => { signalTermination = resolve; });
|
||||
const close = new Promise<Readonly<{ code: number | null; error?: Error; signal: NodeJS.Signals | null }>>((resolve) => {
|
||||
child.once("error", (error) => resolve({ code: null, error, signal: null }));
|
||||
child.once("close", (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
let inputError: Error | undefined;
|
||||
child.stdin?.once("error", (error) => { inputError = error; });
|
||||
if (child.stdin) child.stdin.write(scopeFrame);
|
||||
else inputError = new Error("systemd-run provider argument pipe is unavailable");
|
||||
const terminate = (reason: "guardian" | "timeout" | "output"): void => {
|
||||
if (termination) return;
|
||||
termination = reason;
|
||||
kill = killProviderUnit(unitName);
|
||||
signalTermination();
|
||||
};
|
||||
const guardianLatch = createProviderScopeGuardianLatch(guardianExit);
|
||||
void guardianLatch.activeFailure.then((error) => {
|
||||
guardianError = error;
|
||||
terminate("guardian");
|
||||
});
|
||||
const outputLimiter = createProviderOutputLimiter(
|
||||
PROVIDER_MAX_OUTPUT_BYTES,
|
||||
() => terminate("output"),
|
||||
);
|
||||
const capture = (chunk: Buffer | string): void => {
|
||||
if (termination) return;
|
||||
outputLimiter.consume(chunk);
|
||||
};
|
||||
child.stdout?.on("data", capture);
|
||||
child.stderr?.on("data", capture);
|
||||
const timeout = setTimeout(() => terminate("timeout"), timeoutMs);
|
||||
try {
|
||||
const first = await Promise.race([
|
||||
close.then((result) => ({ type: "close" as const, result })),
|
||||
terminationStarted.then(() => ({ type: "termination" as const })),
|
||||
]);
|
||||
let collection: Promise<void> | undefined;
|
||||
if (first.type === "close" && !termination) {
|
||||
collection = waitForProviderUnitCollected(unitName);
|
||||
const scopeOutcome = await Promise.race([
|
||||
collection.then(() => "collected" as const),
|
||||
terminationStarted.then(() => "termination" as const),
|
||||
]);
|
||||
if (scopeOutcome === "collected") {
|
||||
await guardianLatch.close();
|
||||
const boundaryFailure = guardianLatch.failure();
|
||||
if (boundaryFailure && !termination) {
|
||||
guardianError = boundaryFailure;
|
||||
terminate("guardian");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (termination) {
|
||||
let killError: Error | undefined;
|
||||
try { await kill; } catch (error) { killError = toError(error); child.kill("SIGKILL"); }
|
||||
const closed = await waitForProviderClose(close);
|
||||
if (!closed) child.kill("SIGKILL");
|
||||
let collectionError: Error | undefined;
|
||||
try {
|
||||
await (collection ?? waitForProviderUnitCollected(unitName));
|
||||
} catch (error) {
|
||||
collectionError = toError(error);
|
||||
}
|
||||
await guardianLatch.close();
|
||||
const reason = termination === "timeout"
|
||||
? "sandboxed external provider command timed out"
|
||||
: termination === "output"
|
||||
? `sandboxed external provider output exceeded the ${PROVIDER_MAX_OUTPUT_BYTES}-byte aggregate limit`
|
||||
: `provider raw guardian failed${guardianError ? `: ${guardianError.message}` : ""}`;
|
||||
if (!closed) throw new Error(`${reason} and systemd-run did not close within the reap bound`);
|
||||
if (collectionError) throw new Error(`${reason}; provider cgroup collection failed: ${collectionError.message}`);
|
||||
if (killError) throw new Error(`${reason}; provider cgroup kill failed: ${killError.message}`);
|
||||
throw new Error(reason);
|
||||
}
|
||||
const result = first.type === "close" ? first.result : await close;
|
||||
await collection;
|
||||
if (result.error) throw result.error;
|
||||
if (result.code !== 0 || result.signal !== null) {
|
||||
throw new Error(`sandboxed external provider failed: exit=${result.code ?? "none"}, signal=${result.signal ?? "none"}`);
|
||||
}
|
||||
if (inputError) throw inputError;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
await guardianLatch.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForProviderClose(
|
||||
close: Promise<Readonly<{ code: number | null; error?: Error; signal: NodeJS.Signals | null }>>,
|
||||
): Promise<boolean> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const closed = await Promise.race([
|
||||
close.then(() => true),
|
||||
new Promise<false>((resolve) => { timer = setTimeout(() => resolve(false), PROVIDER_REAP_TIMEOUT_MS); }),
|
||||
]);
|
||||
if (timer) clearTimeout(timer);
|
||||
return closed;
|
||||
}
|
||||
|
||||
async function assertProviderCgroupManagerAvailable(): Promise<void> {
|
||||
const output = await runBoundedSystemctl(
|
||||
["--user", "show", "--property=Version", "--value"],
|
||||
"provider cgroup user manager probe",
|
||||
);
|
||||
const match = /^([0-9]+)/u.exec(output.trim());
|
||||
const version = match ? Number(match[1]) : Number.NaN;
|
||||
if (!Number.isSafeInteger(version) || version < MINIMUM_PROVIDER_SYSTEMD_VERSION) {
|
||||
throw new Error(`provider cgroup unavailable: systemd ${MINIMUM_PROVIDER_SYSTEMD_VERSION} or newer is required`);
|
||||
}
|
||||
}
|
||||
|
||||
async function killProviderUnit(unitName: string): Promise<void> {
|
||||
await runBoundedSystemctl(systemctlKillProviderArguments(unitName), "provider cgroup unit kill");
|
||||
}
|
||||
|
||||
async function waitForProviderUnitCollected(unitName: string): Promise<void> {
|
||||
const deadline = Date.now() + PROVIDER_CONTROL_TIMEOUT_MS;
|
||||
let loadState = "unknown";
|
||||
while (Date.now() <= deadline) {
|
||||
loadState = (await runBoundedSystemctl(
|
||||
["--user", "show", unitName, "--property=LoadState", "--value"],
|
||||
"provider cgroup collection probe",
|
||||
)).trim();
|
||||
if (loadState === "not-found") return;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, PROVIDER_CONTROL_POLL_MS));
|
||||
}
|
||||
throw new Error(`provider unit remained loaded with state ${loadState || "unknown"}`);
|
||||
}
|
||||
|
||||
async function runBoundedSystemctl(arguments_: readonly string[], label: string): Promise<string> {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const child = spawn("/usr/bin/systemctl", arguments_, {
|
||||
env: providerCgroupClientEnvironment(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let bytes = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
let settled = false;
|
||||
let termination: "output" | "timeout" | undefined;
|
||||
let reap: NodeJS.Timeout | undefined;
|
||||
const finish = (error?: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
if (reap) clearTimeout(reap);
|
||||
error ? reject(error) : resolve(Buffer.concat(chunks).toString("utf8"));
|
||||
};
|
||||
const terminate = (reason: "output" | "timeout"): void => {
|
||||
if (termination) return;
|
||||
termination = reason;
|
||||
child.kill("SIGKILL");
|
||||
reap = setTimeout(() => finish(new Error(`${label} ${reason} bound was exceeded and systemctl did not close`)), PROVIDER_REAP_TIMEOUT_MS);
|
||||
};
|
||||
const capture = (chunk: Buffer | string): void => {
|
||||
if (termination) return;
|
||||
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
const remaining = PROVIDER_CONTROL_MAX_OUTPUT_BYTES - bytes;
|
||||
if (remaining > 0) { chunks.push(value.subarray(0, remaining)); bytes += Math.min(value.length, remaining); }
|
||||
if (value.length > remaining) terminate("output");
|
||||
};
|
||||
child.stdout?.on("data", capture);
|
||||
child.stderr?.on("data", capture);
|
||||
const timeout = setTimeout(() => terminate("timeout"), PROVIDER_CONTROL_TIMEOUT_MS);
|
||||
child.once("error", (error) => finish(error));
|
||||
child.once("close", (code, signal) => {
|
||||
if (termination) return finish(new Error(`${label} ${termination} bound was exceeded`));
|
||||
if (code === 0 && signal === null) return finish();
|
||||
const detail = Buffer.concat(chunks).toString("utf8").trim();
|
||||
finish(new Error(`${label} failed: exit=${code ?? "none"}, signal=${signal ?? "none"}${detail ? `, output=${detail}` : ""}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function providerCgroupClientEnvironment(): NodeJS.ProcessEnv {
|
||||
const environment: NodeJS.ProcessEnv = { PATH: "/usr/bin:/bin" };
|
||||
for (const name of ["DBUS_SESSION_BUS_ADDRESS", "HOME", "LANG", "LC_ALL", "LOGNAME", "USER", "XDG_RUNTIME_DIR"] as const) {
|
||||
if (process.env[name]) environment[name] = process.env[name];
|
||||
}
|
||||
return environment;
|
||||
}
|
||||
|
||||
function providerCpuSeconds(): number {
|
||||
const value = process.env.PROVIDER_SUPERVISOR_CPU_SECONDS;
|
||||
if (!value) return DEFAULT_PROVIDER_CPU_SECONDS;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > DEFAULT_PROVIDER_CPU_SECONDS) {
|
||||
throw new TypeError("PROVIDER_SUPERVISOR_CPU_SECONDS must be a positive integer no greater than 1200");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function providerTimeoutMs(): number {
|
||||
const value = process.env.PROVIDER_SUPERVISOR_TIMEOUT_MS;
|
||||
if (!value) return DEFAULT_PROVIDER_TIMEOUT_MS;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > DEFAULT_PROVIDER_TIMEOUT_MS) {
|
||||
throw new TypeError("PROVIDER_SUPERVISOR_TIMEOUT_MS must be a positive integer no greater than 1800000");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
function missingDestinationAncestors(target: string): string[] {
|
||||
@@ -269,6 +578,21 @@ async function prepareMissingProviderOutput(
|
||||
}
|
||||
}
|
||||
|
||||
async function assertOwnedProviderOutput(
|
||||
absolutePath: string,
|
||||
identity: Readonly<{ dev: number; ino: number }>,
|
||||
): Promise<void> {
|
||||
const metadata = await lstat(absolutePath);
|
||||
if (
|
||||
metadata.isSymbolicLink() ||
|
||||
!metadata.isFile() ||
|
||||
metadata.dev !== identity.dev ||
|
||||
metadata.ino !== identity.ino
|
||||
) {
|
||||
throw new Error("provider raw output identity changed");
|
||||
}
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
||||
}
|
||||
|
||||
+8
-12
@@ -9,23 +9,17 @@ import {
|
||||
isValidSourceDateEpoch,
|
||||
} from "./lib/build-environment.ts";
|
||||
import { classifyGateStepResult } from "./lib/ci-step-result.ts";
|
||||
import {
|
||||
indexCiGateContract,
|
||||
loadCiGateContract,
|
||||
} from "./contracts/ci-gates.ts";
|
||||
import { withCiGatePreflight } from "./contracts/ci-gates.ts";
|
||||
import { validateCiArtifact } from "./lib/ci-artifact-validator.ts";
|
||||
import { writeCiGateLogAtomic } from "./lib/ci-gate-log.ts";
|
||||
|
||||
const gateId = process.argv
|
||||
const requestedGateId = process.argv
|
||||
.slice(2)
|
||||
.find((argument) => /^FE-GATE-\d{3}$/.test(argument));
|
||||
const contract = await loadCiGateContract(process.cwd());
|
||||
const contractIndex = indexCiGateContract(contract);
|
||||
const gate = gateId ? contractIndex.gates.get(gateId) : undefined;
|
||||
if (!gateId || !gate) {
|
||||
process.stderr.write("Usage: ci:gate -- FE-GATE-001..FE-GATE-026\n");
|
||||
process.exit(2);
|
||||
}
|
||||
await withCiGatePreflight(
|
||||
process.cwd(),
|
||||
requestedGateId,
|
||||
async ({ contractIndex, gateId, gate }) => {
|
||||
|
||||
const logArtifact = contractIndex.artifacts.get(gate.logArtifactId);
|
||||
if (!logArtifact) throw new TypeError(`CI gate log artifact disappeared: ${gate.logArtifactId}`);
|
||||
@@ -252,4 +246,6 @@ if (!passed) {
|
||||
}
|
||||
process.stdout.write(
|
||||
`${gateId} ${gate.name}: PASS (${gate.retentionClassId})\n`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,44 +1,3 @@
|
||||
import { appendFile } from "node:fs/promises";
|
||||
import { runStageVerifiedPromotionCli } from "./lib/stage-verified-promotion-cli.ts";
|
||||
|
||||
import { finalizeVerifiedPromotion } from "./lib/promotion-stager.ts";
|
||||
|
||||
const required = (name: string): string => {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new TypeError(`promotion staging environment is missing ${name}`);
|
||||
return value;
|
||||
};
|
||||
|
||||
const staged = await finalizeVerifiedPromotion({
|
||||
repositoryRoot: process.cwd(),
|
||||
archivePath: required("CANDIDATE_ARCHIVE_PATH"),
|
||||
expectedArchiveSha256: required("CANDIDATE_ARCHIVE_SHA256"),
|
||||
vulnerabilityReportPath: required("VULNERABILITY_REPORT_PATH"),
|
||||
provenanceAttestationPath: required("PROVENANCE_ATTESTATION_PATH"),
|
||||
vulnerabilityPublicKeyPath: required("VULNERABILITY_PUBLIC_KEY_PATH"),
|
||||
vulnerabilityKeyId: required("VULNERABILITY_KEY_ID"),
|
||||
provenancePublicKeyPath: required("PROVENANCE_PUBLIC_KEY_PATH"),
|
||||
provenanceKeyId: required("PROVENANCE_KEY_ID"),
|
||||
expectedRun: {
|
||||
id: process.env.GITEA_RUN_ID ?? process.env.GITHUB_RUN_ID ?? required("CI_RUN_ID"),
|
||||
attempt: Number(process.env.GITEA_RUN_ATTEMPT ?? process.env.GITHUB_RUN_ATTEMPT ?? required("CI_RUN_ATTEMPT")),
|
||||
sourceRevision: process.env.EXPECTED_SOURCE_REVISION ?? required("VITE_COMMIT_SHA"),
|
||||
},
|
||||
vulnerabilityInvocationNonce: required("VULNERABILITY_INVOCATION_NONCE"),
|
||||
provenanceInvocationNonce: required("PROVENANCE_INVOCATION_NONCE"),
|
||||
runnerTempRoot: required("RUNNER_TEMP"),
|
||||
});
|
||||
const output = required("GITHUB_OUTPUT");
|
||||
await appendFile(
|
||||
output,
|
||||
[
|
||||
`staging_root=${staged.stagingRoot}`,
|
||||
`cleanup_token=${staged.cleanupToken}`,
|
||||
`runner_temp_dev=${staged.runnerTempIdentity.dev}`,
|
||||
`runner_temp_ino=${staged.runnerTempIdentity.ino}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
process.stdout.write(
|
||||
`Promotion staging: ${staged.files.map(({ name, sha256 }) => `${name}=${sha256}`).join(", ")} PASS\n`,
|
||||
);
|
||||
await runStageVerifiedPromotionCli(process.env);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
|
||||
import { manualA11yReportArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||
import {
|
||||
MANUAL_A11Y_ROUTE_IDS,
|
||||
validateManualA11yEvidence,
|
||||
} from "./lib/manual-a11y-evidence.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
type ManualA11yResult = Readonly<{
|
||||
routeId: string;
|
||||
@@ -34,34 +36,34 @@ for (const routeId of MANUAL_A11Y_ROUTE_IDS) {
|
||||
passed: validation.passed && failures.length === 0,
|
||||
});
|
||||
}
|
||||
const releaseIds = new Set(results.map((result) => result.releaseId));
|
||||
const releaseIds = new Set(
|
||||
results.map((result) => result.releaseId).filter((releaseId): releaseId is string => Boolean(releaseId)),
|
||||
);
|
||||
const coherentRelease =
|
||||
releaseIds.size === 1 && results.every((result) => Boolean(result.releaseId));
|
||||
const passed =
|
||||
results.every((result) => result.passed) &&
|
||||
releaseIds.size === 1 &&
|
||||
results.every((result) => Boolean(result.releaseId));
|
||||
coherentRelease;
|
||||
|
||||
await mkdir("artifacts/tests/a11y-manual", { recursive: true });
|
||||
await writeFile(
|
||||
"artifacts/tests/a11y-manual/report.json",
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
scope: MANUAL_A11Y_ROUTE_IDS,
|
||||
results,
|
||||
coherentRelease: releaseIds.size === 1,
|
||||
passed,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await writeValidatedJsonArtifact({
|
||||
path: "artifacts/tests/a11y-manual/report.json",
|
||||
schema: manualA11yReportArtifactSchema,
|
||||
value: {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
scope: MANUAL_A11Y_ROUTE_IDS,
|
||||
results,
|
||||
coherentRelease,
|
||||
passed,
|
||||
},
|
||||
});
|
||||
|
||||
if (!passed) {
|
||||
const failures = results
|
||||
.filter((result) => !result.passed)
|
||||
.map((result) => `${result.routeId}: ${result.failures.join(", ")}`);
|
||||
if (releaseIds.size !== 1) failures.push("release IDs do not match");
|
||||
if (!coherentRelease) failures.push("release IDs do not match");
|
||||
process.stderr.write(
|
||||
`Manual accessibility evidence is incomplete:\n${failures.join("\n")}\n`,
|
||||
);
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
captureCiCandidateArchive,
|
||||
withVerifiedCapturedCandidate,
|
||||
} from "./lib/ci-candidate-archive.ts";
|
||||
import { verifyArchivedLocalEvidence } from "./lib/local-release-evidence.ts";
|
||||
import {
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
releaseCandidateManifestSchema,
|
||||
} from "./lib/release-candidate.ts";
|
||||
|
||||
const candidate = releaseCandidateManifestSchema.parse(
|
||||
JSON.parse(await readFile(RELEASE_CANDIDATE_MANIFEST_PATH, "utf8")),
|
||||
);
|
||||
const result = await verifyArchivedLocalEvidence({
|
||||
extractionRoot: process.cwd(),
|
||||
expectedManifest: candidate,
|
||||
});
|
||||
const argument = (name: string): string | undefined => {
|
||||
const index = process.argv.indexOf(name);
|
||||
if (index < 0) return undefined;
|
||||
const value = process.argv[index + 1];
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new TypeError(`${name} requires a value`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const archivePath = argument("--archive");
|
||||
const expectedSha256 = argument("--sha256");
|
||||
if (Boolean(archivePath) !== Boolean(expectedSha256)) {
|
||||
throw new TypeError("--archive and --sha256 must be supplied together");
|
||||
}
|
||||
const result = archivePath && expectedSha256
|
||||
? await withVerifiedCapturedCandidate({
|
||||
captured: await captureCiCandidateArchive({ archivePath, expectedSha256 }),
|
||||
verify: ({ extractionRoot, manifest }) =>
|
||||
verifyArchivedLocalEvidence({ extractionRoot, expectedManifest: manifest }),
|
||||
})
|
||||
: await verifyCheckoutEvidence();
|
||||
if (result.status !== "PASS") {
|
||||
process.stderr.write(
|
||||
`Archived local evidence verification failed:\n- ${result.failures.join("\n- ")}\n`,
|
||||
@@ -20,3 +38,13 @@ if (result.status !== "PASS") {
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write("Archived local evidence verification: PASS\n");
|
||||
|
||||
async function verifyCheckoutEvidence() {
|
||||
const candidate = releaseCandidateManifestSchema.parse(
|
||||
JSON.parse(await readFile(RELEASE_CANDIDATE_MANIFEST_PATH, "utf8")),
|
||||
);
|
||||
return verifyArchivedLocalEvidence({
|
||||
extractionRoot: process.cwd(),
|
||||
expectedManifest: candidate,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
|
||||
import { documentationReviewArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
type DocumentationReview = Readonly<{
|
||||
sourcePath: string;
|
||||
@@ -60,24 +63,21 @@ const passed =
|
||||
results.length === 2 &&
|
||||
results.every((result) => result.passed);
|
||||
await mkdir("artifacts/quality", { recursive: true });
|
||||
await writeFile(
|
||||
"artifacts/quality/documentation-review.json",
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
status: ledger.status,
|
||||
reviewer: ledger.reviewer,
|
||||
standard: ledger.standard,
|
||||
evidenceReport: ledger.evidenceReport,
|
||||
reportDigestValid,
|
||||
results,
|
||||
passed,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await writeValidatedJsonArtifact({
|
||||
path: "artifacts/quality/documentation-review.json",
|
||||
schema: documentationReviewArtifactSchema,
|
||||
value: {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
status: ledger.status,
|
||||
reviewer: ledger.reviewer,
|
||||
standard: ledger.standard,
|
||||
evidenceReport: ledger.evidenceReport,
|
||||
reportDigestValid,
|
||||
results,
|
||||
passed,
|
||||
},
|
||||
});
|
||||
if (!passed) {
|
||||
process.stderr.write(
|
||||
"Documentation readiness: FAIL_UNVERIFIED (canonical scoped-review evidence is incomplete)\n",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import path from "node:path";
|
||||
import { readdir } from "node:fs/promises";
|
||||
|
||||
import { PROMOTED_FILE_NAMES } from "./contracts/promotion-artifacts.ts";
|
||||
import { readBoundedRegularFile } from "./lib/ci-artifact-validator.ts";
|
||||
import { verifyExactPromotionBundle } from "./lib/exact-promotion-bundle.ts";
|
||||
import { readProviderTrust } from "./lib/provider-trust.ts";
|
||||
|
||||
const required = (name: string): string => {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new TypeError(`exact promotion verification environment is missing ${name}`);
|
||||
return value;
|
||||
};
|
||||
const rootArgument = process.argv.indexOf("--root");
|
||||
const bundleRoot = path.resolve(
|
||||
rootArgument >= 0
|
||||
? process.argv[rootArgument + 1] ?? ""
|
||||
: required("PROMOTION_BUNDLE_ROOT"),
|
||||
);
|
||||
if (rootArgument >= 0 && !process.argv[rootArgument + 1]) {
|
||||
throw new TypeError("--root requires a promotion bundle directory");
|
||||
}
|
||||
const trustRoot = process.cwd();
|
||||
const [vulnerabilityTrust, provenanceTrust] = await Promise.all([
|
||||
readProviderTrust(
|
||||
trustRoot,
|
||||
required("VULNERABILITY_PUBLIC_KEY_PATH"),
|
||||
required("VULNERABILITY_KEY_ID"),
|
||||
),
|
||||
readProviderTrust(
|
||||
trustRoot,
|
||||
required("PROVENANCE_PUBLIC_KEY_PATH"),
|
||||
required("PROVENANCE_KEY_ID"),
|
||||
),
|
||||
]);
|
||||
if (!vulnerabilityTrust || !provenanceTrust) {
|
||||
throw new TypeError("exact promotion verification trust keys are invalid");
|
||||
}
|
||||
const names = (await readdir(bundleRoot)).sort(asciiCompare);
|
||||
if (
|
||||
JSON.stringify(names) !==
|
||||
JSON.stringify([...PROMOTED_FILE_NAMES].sort(asciiCompare))
|
||||
) {
|
||||
throw new TypeError("promotion bundle directory must contain exactly the canonical five files");
|
||||
}
|
||||
const files = Object.fromEntries(
|
||||
await Promise.all(
|
||||
PROMOTED_FILE_NAMES.map(async (name) => [
|
||||
name,
|
||||
await readBoundedRegularFile({
|
||||
root: bundleRoot,
|
||||
relativePath: name,
|
||||
maxBytes: name === "release-candidate.tar.gz" ? 268_435_456 : 16_777_216,
|
||||
}),
|
||||
] as const),
|
||||
),
|
||||
);
|
||||
await verifyExactPromotionBundle(files, {
|
||||
vulnerabilityTrust,
|
||||
provenanceTrust,
|
||||
expected: {
|
||||
run: {
|
||||
id: required("EXPECTED_PROMOTION_RUN_ID"),
|
||||
attempt: requiredPositiveInteger("EXPECTED_PROMOTION_RUN_ATTEMPT"),
|
||||
},
|
||||
sourceRevision: required("EXPECTED_PROMOTION_SOURCE_REVISION"),
|
||||
archiveSha256: required("EXPECTED_PROMOTION_ARCHIVE_SHA256"),
|
||||
...optionalDigest("sourceSetSha256", "EXPECTED_PROMOTION_SOURCE_SET_SHA256"),
|
||||
...optionalDigest("bundleSha256", "EXPECTED_PROMOTION_BUNDLE_SHA256"),
|
||||
...optionalDigest("distSha256", "EXPECTED_PROMOTION_DIST_SHA256"),
|
||||
...optionalDigest("lockfileSha256", "EXPECTED_PROMOTION_LOCKFILE_SHA256"),
|
||||
},
|
||||
});
|
||||
process.stdout.write("Exact promotion bundle verification: PASS\n");
|
||||
|
||||
function asciiCompare(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
function requiredPositiveInteger(name: string): number {
|
||||
const value = required(name);
|
||||
if (!/^[1-9][0-9]*$/u.test(value) || !Number.isSafeInteger(Number(value))) {
|
||||
throw new TypeError(`${name} must be a positive safe integer`);
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function optionalDigest(
|
||||
property: "sourceSetSha256" | "bundleSha256" | "distSha256" | "lockfileSha256",
|
||||
environmentName: string,
|
||||
): Readonly<Record<string, string>> {
|
||||
const value = process.env[environmentName];
|
||||
return value ? { [property]: value } : {};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, readdir } from "node:fs/promises";
|
||||
|
||||
import { hostingHeadersArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||
import { classifyLiveHostingBaseUrl } from "./lib/hosting-probe.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
type Document = Record<string, unknown>;
|
||||
type ResponseHeaders = Record<string, Record<string, string>>;
|
||||
@@ -194,22 +196,19 @@ results.push({
|
||||
|
||||
const passed = results.every((result) => result.passed);
|
||||
await mkdir("artifacts/release", { recursive: true });
|
||||
await writeFile(
|
||||
"artifacts/release/hosting-headers.json",
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
mode,
|
||||
baseUrl: liveTarget?.observedOrigin ?? null,
|
||||
providerVerificationRequired: mode !== "live",
|
||||
results,
|
||||
passed,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await writeValidatedJsonArtifact({
|
||||
path: "artifacts/release/hosting-headers.json",
|
||||
schema: hostingHeadersArtifactSchema,
|
||||
value: {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
mode,
|
||||
baseUrl: liveTarget?.observedOrigin ?? null,
|
||||
providerVerificationRequired: mode !== "live",
|
||||
results,
|
||||
passed,
|
||||
},
|
||||
});
|
||||
|
||||
if (!passed) {
|
||||
process.stderr.write(
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { providerVerificationArtifactSchema } from "./lib/provider-evidence.ts";
|
||||
import { verifyPromotionInputs } from "./lib/promotion-verifier.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
const report = await verifyPromotionInputs({
|
||||
artifactType: "provider-verification",
|
||||
repositoryRoot: path.resolve(process.env.CANDIDATE_ROOT ?? process.cwd()),
|
||||
providerEvidenceRoot: process.cwd(),
|
||||
trustRoot: process.cwd(),
|
||||
});
|
||||
await mkdir("artifacts/security", { recursive: true });
|
||||
await writeValidatedJsonArtifact({
|
||||
path: "artifacts/security/provider-verification.json",
|
||||
schema: providerVerificationArtifactSchema,
|
||||
value: report,
|
||||
});
|
||||
if (report.status !== "PASS") {
|
||||
process.stderr.write(
|
||||
`Provider evidence is FAIL_UNVERIFIED:\n- ${report.failures.join("\n- ")}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write("Provider evidence: PASS\n");
|
||||
@@ -1,9 +1,11 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { reproducibleBuildArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||
import { assertCiBuildEnvironment } from "./lib/build-environment.ts";
|
||||
import { supplyChainDigest } from "./lib/supply-chain.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
assertCiBuildEnvironment(process.env);
|
||||
|
||||
@@ -54,26 +56,23 @@ const passed =
|
||||
firstDigest === secondDigest;
|
||||
|
||||
await mkdir("artifacts/release", { recursive: true });
|
||||
await writeFile(
|
||||
"artifacts/release/reproducible-build.json",
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
sourceDateEpoch: deterministicEnvironment.SOURCE_DATE_EPOCH,
|
||||
buildId: process.env.VITE_BUILD_ID ?? "local-build",
|
||||
commitSha: process.env.VITE_COMMIT_SHA ?? "local",
|
||||
releaseId: process.env.RELEASE_ID ?? "local-release",
|
||||
runnerImage:
|
||||
process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`,
|
||||
firstDigest,
|
||||
secondDigest,
|
||||
restored: restoreBuild.status === 0,
|
||||
status: passed ? "PASS" : "FAIL",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await writeValidatedJsonArtifact({
|
||||
path: "artifacts/release/reproducible-build.json",
|
||||
schema: reproducibleBuildArtifactSchema,
|
||||
value: {
|
||||
schemaVersion: 1,
|
||||
sourceDateEpoch: deterministicEnvironment.SOURCE_DATE_EPOCH,
|
||||
buildId: process.env.VITE_BUILD_ID ?? "local-build",
|
||||
commitSha: process.env.VITE_COMMIT_SHA ?? "local",
|
||||
releaseId: process.env.RELEASE_ID ?? "local-release",
|
||||
runnerImage:
|
||||
process.env.CI_RUNNER_IMAGE ?? `${process.platform}-${process.arch}`,
|
||||
firstDigest,
|
||||
secondDigest,
|
||||
restored: restoreBuild.status === 0,
|
||||
status: passed ? "PASS" : "FAIL",
|
||||
},
|
||||
});
|
||||
if (!passed) {
|
||||
process.stderr.write(
|
||||
`Reproducible build failed: first=${firstDigest} second=${secondDigest}\n`,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { providerVerificationArtifactSchema } from "./lib/provider-evidence.ts";
|
||||
import { verifyPromotionInputs } from "./lib/promotion-verifier.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
const report = await verifyPromotionInputs({
|
||||
artifactType: "promotion-verification",
|
||||
repositoryRoot: path.resolve(process.env.CANDIDATE_ROOT ?? process.cwd()),
|
||||
providerEvidenceRoot: process.cwd(),
|
||||
trustRoot: process.cwd(),
|
||||
});
|
||||
await mkdir("artifacts/security", { recursive: true });
|
||||
await writeValidatedJsonArtifact({
|
||||
path: "artifacts/security/promotion-verification.json",
|
||||
schema: providerVerificationArtifactSchema,
|
||||
value: report,
|
||||
});
|
||||
if (report.status !== "PASS") {
|
||||
process.stderr.write(
|
||||
`Supply-chain promotion is FAIL_UNVERIFIED:\n- ${report.failures.join("\n- ")}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write("Supply-chain promotion evidence: PASS\n");
|
||||
@@ -1,20 +1,19 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
|
||||
import { automatedA11yArtifactSchema } from "./contracts/release-artifacts.ts";
|
||||
import { MANUAL_A11Y_ROUTE_IDS } from "./lib/manual-a11y-evidence.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
await mkdir("artifacts/tests", { recursive: true });
|
||||
await writeFile(
|
||||
"artifacts/tests/a11y.json",
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
scope: MANUAL_A11Y_ROUTE_IDS,
|
||||
threshold: { critical: 0, serious: 0 },
|
||||
automatedStatus: "passed",
|
||||
manualReview: "see artifacts/tests/a11y-manual/report.json",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await writeValidatedJsonArtifact({
|
||||
path: "artifacts/tests/a11y.json",
|
||||
schema: automatedA11yArtifactSchema,
|
||||
value: {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
scope: MANUAL_A11Y_ROUTE_IDS,
|
||||
threshold: { critical: 0, serious: 0 },
|
||||
automatedStatus: "passed",
|
||||
manualReview: "see artifacts/tests/a11y-manual/report.json",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user