refactor: adapter 구현중..

This commit is contained in:
DongHyeonka
2026-08-13 16:02:21 +09:00
parent 30ceac23c1
commit 4dc033cf33
72 changed files with 13370 additions and 1549 deletions
+298 -11
View File
@@ -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) ||
+353
View File
@@ -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;
}
+200 -35
View File
@@ -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,
+956 -16
View File
@@ -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
View File
@@ -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();
-333
View File
@@ -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,
});
}
+169
View File
@@ -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");
}
+46
View File
@@ -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");
}
+763
View File
@@ -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));
}
+396
View File
@@ -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]);
}
+27
View File
@@ -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,
});
}
+103
View File
@@ -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);
}
+40
View File
@@ -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);
}
+641
View File
@@ -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);
}
+166
View File
@@ -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);
}
+24 -4
View File
@@ -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,
});
}
+42
View File
@@ -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;
}
}
+37
View File
@@ -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<{
+1 -1
View File
@@ -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`),
+12 -1
View File
@@ -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" });
}
+13 -6
View File
@@ -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;