2395 lines
104 KiB
TypeScript
2395 lines
104 KiB
TypeScript
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
import { createHash, generateKeyPairSync, sign } from "node:crypto";
|
|
import { constants } from "node:fs";
|
|
import { cp, link, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
|
|
import { afterAll, afterEach, describe, expect, it } from "vitest";
|
|
|
|
import type {
|
|
CiGateArtifact,
|
|
CiGateArtifactSchema,
|
|
} from "../../scripts/contracts/ci-gates.ts";
|
|
import { parseCiGateContract } from "../../scripts/contracts/ci-gates.ts";
|
|
import {
|
|
hasCiArtifactSemanticValidator,
|
|
readBoundedRegularFile,
|
|
validateCiArtifact,
|
|
} from "../../scripts/lib/ci-artifact-validator.ts";
|
|
import { writeCiGateLogAtomic } from "../../scripts/lib/ci-gate-log.ts";
|
|
import { captureCiCandidateArchive, verifyCiCandidateArchive } from "../../scripts/lib/ci-candidate-archive.ts";
|
|
import {
|
|
CANDIDATE_ARCHIVE_USAGE,
|
|
parseCandidateArchiveArguments,
|
|
} from "../../scripts/lib/ci-candidate-archive-cli.ts";
|
|
import { validateProviderUpload } from "../../scripts/lib/provider-upload-validator.ts";
|
|
import { verifyExactPromotionBundle } from "../../scripts/lib/exact-promotion-bundle.ts";
|
|
import {
|
|
cleanupFinalizedPromotion,
|
|
stageVerifiedPromotion,
|
|
} from "../../scripts/lib/promotion-stager.ts";
|
|
import { PROMOTED_FILE_NAMES } from "../../scripts/contracts/promotion-artifacts.ts";
|
|
import {
|
|
providerEvidenceSignaturePayload,
|
|
providerPublicKeyFingerprint,
|
|
providerVerificationArtifactSchema,
|
|
} from "../../scripts/lib/provider-evidence.ts";
|
|
import { localEvidenceAssessmentArtifactSchema } from "../../scripts/contracts/release-artifacts.ts";
|
|
import { readProviderTrust } from "../../scripts/lib/provider-trust.ts";
|
|
import {
|
|
createReleaseCandidateManifest,
|
|
LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
|
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
|
RELEASE_CANDIDATE_MANIFEST_PATH,
|
|
} from "../../scripts/lib/release-candidate.ts";
|
|
|
|
const temporaryRoots: string[] = [];
|
|
let providerBaseRoot: string | undefined;
|
|
const sha256 = (value: Buffer | string) =>
|
|
createHash("sha256").update(value).digest("hex");
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
|
);
|
|
});
|
|
afterAll(async () => {
|
|
if (providerBaseRoot) {
|
|
await rm(providerBaseRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
async function temporaryRoot(prefix: string): Promise<string> {
|
|
const root = await mkdtemp(path.join(tmpdir(), prefix));
|
|
temporaryRoots.push(root);
|
|
return root;
|
|
}
|
|
|
|
async function writeArtifact(root: string, relative: string, value: string | Buffer) {
|
|
await mkdir(path.dirname(path.join(root, relative)), { recursive: true });
|
|
await writeFile(path.join(root, relative), value);
|
|
}
|
|
|
|
function artifact(pathname: string, schemaId: string): CiGateArtifact {
|
|
return {
|
|
id: `artifact-${schemaId}`,
|
|
path: pathname,
|
|
schemaId,
|
|
production: "source-controlled",
|
|
};
|
|
}
|
|
|
|
describe("CI artifact validator", () => {
|
|
it("does not create directories through a pre-existing log ancestor symlink", async () => {
|
|
const root = await temporaryRoot("ci-log-root-");
|
|
const outside = await temporaryRoot("ci-log-outside-");
|
|
await symlink(outside, path.join(root, "linked"));
|
|
await expect(
|
|
writeCiGateLogAtomic({
|
|
root,
|
|
relativePath: "linked/new/report.txt",
|
|
content: "blocked\n",
|
|
}),
|
|
).rejects.toThrow(/ancestor is unsafe/i);
|
|
await expect(
|
|
import("node:fs/promises").then(({ lstat }) => lstat(path.join(outside, "new"))),
|
|
).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it.each([
|
|
["report.txt", { id: "text", kind: "text", maxBytes: 1_024 }, "gate output\n"],
|
|
["report.xml", { id: "junit", kind: "junit", maxBytes: 1_024 }, '<testsuite name="one"/>\n'],
|
|
["report.html", { id: "html", kind: "html", maxBytes: 1_024 }, "<!doctype html><html></html>\n"],
|
|
["report.md", { id: "markdown", kind: "markdown", maxBytes: 1_024 }, "# Review\n"],
|
|
["schema.json", { id: "json-schema", kind: "json-schema", maxBytes: 1_024 }, '{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object"}\n'],
|
|
["scan.sarif", { id: "sarif", kind: "sarif", maxBytes: 4_096 }, JSON.stringify({ version: "2.1.0", $schema: "https://json.schemastore.org/sarif-2.1.0.json", runs: [{ tool: { driver: { name: "ca-frontend-secret-scan", rules: [] } }, results: [] }] })],
|
|
] as const)("accepts a valid %s artifact", async (relative, schema, content) => {
|
|
const root = await temporaryRoot("ci-artifact-kind-");
|
|
await writeArtifact(root, relative, content);
|
|
await expect(
|
|
validateCiArtifact({
|
|
root,
|
|
artifact: artifact(relative, schema.id),
|
|
schema: schema as CiGateArtifactSchema,
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("accepts schema-valid negative evidence without treating status as command authority", async () => {
|
|
const root = await temporaryRoot("ci-artifact-negative-");
|
|
const relative = "negative.json";
|
|
await writeArtifact(
|
|
root,
|
|
relative,
|
|
`${JSON.stringify({ schemaVersion: 2, sourceRoot: "src", status: "FAIL", facts: { scannedFiles: 0, visualBaselines: 0, sharedScenarios: 0, declaredScenarioExecutions: 0, executedScenarioExecutions: 0 }, failures: ["fixture"] })}\n`,
|
|
);
|
|
await expect(
|
|
validateCiArtifact({
|
|
root,
|
|
artifact: artifact(relative, "test-evidence"),
|
|
schema: {
|
|
id: "test-evidence",
|
|
kind: "json",
|
|
maxBytes: 4_096,
|
|
executableSchemaId: "test-evidence-report",
|
|
},
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
const invalidFixtures: ReadonlyArray<
|
|
readonly [string, (root: string) => Promise<void>, RegExp]
|
|
> = [
|
|
["missing", async (_root: string): Promise<void> => undefined, /not a regular file|ENOENT/i],
|
|
["empty", async (root: string): Promise<void> => { await writeArtifact(root, "report.json", ""); }, /size is outside/i],
|
|
["directory", async (root: string): Promise<void> => { await mkdir(path.join(root, "report.json")); }, /not a regular file/i],
|
|
["oversized", async (root: string): Promise<void> => { await writeArtifact(root, "report.json", "12345"); }, /size is outside/i],
|
|
["invalid UTF-8", async (root: string): Promise<void> => { await writeArtifact(root, "report.json", Buffer.from([0xc3, 0x28])); }, /encoded data was not valid|UTF-8/i],
|
|
["primitive JSON", async (root: string): Promise<void> => { await writeArtifact(root, "report.json", "1\n"); }, /object/i],
|
|
["array JSON", async (root: string): Promise<void> => { await writeArtifact(root, "report.json", "[]\n"); }, /object/i],
|
|
];
|
|
it.each(invalidFixtures)("rejects %s artifacts", async (_name, setup, diagnostic) => {
|
|
const root = await temporaryRoot("ci-artifact-invalid-");
|
|
await setup(root);
|
|
await expect(
|
|
validateCiArtifact({
|
|
root,
|
|
artifact: artifact("report.json", "ci-contract"),
|
|
schema: {
|
|
id: "ci-contract",
|
|
kind: "json",
|
|
maxBytes: _name === "oversized" ? 4 : 4_096,
|
|
executableSchemaId: "ci-contract-report",
|
|
},
|
|
}),
|
|
).rejects.toThrow(diagnostic);
|
|
});
|
|
|
|
it("rejects leaf and ancestor symlinks before opening evidence", async () => {
|
|
const root = await temporaryRoot("ci-artifact-symlink-");
|
|
await writeArtifact(root, "real.json", "{\"ok\":true}\n");
|
|
await symlink("real.json", path.join(root, "leaf.json"));
|
|
await mkdir(path.join(root, "real-directory"));
|
|
await writeArtifact(root, "real-directory/report.json", "{\"ok\":true}\n");
|
|
await symlink("real-directory", path.join(root, "linked-directory"));
|
|
const schema = {
|
|
id: "ci-contract",
|
|
kind: "json",
|
|
maxBytes: 4_096,
|
|
executableSchemaId: "ci-contract-report",
|
|
} as const;
|
|
await expect(
|
|
validateCiArtifact({ root, artifact: artifact("leaf.json", "ci-contract"), schema }),
|
|
).rejects.toThrow(/not a regular file/i);
|
|
await expect(
|
|
validateCiArtifact({
|
|
root,
|
|
artifact: artifact("linked-directory/report.json", "ci-contract"),
|
|
schema,
|
|
}),
|
|
).rejects.toThrow(/ancestor is unsafe/i);
|
|
});
|
|
|
|
it("rejects strict JSON evidence with unknown fields", async () => {
|
|
const root = await temporaryRoot("ci-artifact-strict-");
|
|
await writeArtifact(
|
|
root,
|
|
"report.json",
|
|
`${JSON.stringify({ schemaVersion: 2, nodeVersion: "24.14.0", gateCount: 26, commandDefinitionCount: 81, commandReferenceCount: 93, artifactCount: 105, jobCount: 9, workflowSha256: "a".repeat(64), durationStatus: "UNSUPPORTED", negativeFixtures: [], failures: [], passed: true, unknown: true })}\n`,
|
|
);
|
|
await expect(
|
|
validateCiArtifact({
|
|
root,
|
|
artifact: artifact("report.json", "ci-contract"),
|
|
schema: {
|
|
id: "ci-contract",
|
|
kind: "json",
|
|
maxBytes: 8_192,
|
|
executableSchemaId: "ci-contract-report",
|
|
},
|
|
}),
|
|
).rejects.toThrow(/unrecognized|unknown/i);
|
|
});
|
|
|
|
it.each([
|
|
["broken.xml", { id: "junit", kind: "junit", maxBytes: 1_024 }, "<testsuite>"],
|
|
["mismatched.xml", { id: "junit", kind: "junit", maxBytes: 1_024 }, "<testsuites><testsuite></testsuites></testsuite>"],
|
|
["trailing.xml", { id: "junit", kind: "junit", maxBytes: 1_024 }, "<testsuite/>garbage"],
|
|
["doctype.xml", { id: "junit", kind: "junit", maxBytes: 1_024 }, "<!DOCTYPE testsuite [<!ENTITY x SYSTEM 'file:///etc/passwd'>]><testsuite/>"],
|
|
["entity.xml", { id: "junit", kind: "junit", maxBytes: 1_024 }, "<testsuite name=\"&unapproved;\"/>"],
|
|
["numeric-entity.xml", { id: "junit", kind: "junit", maxBytes: 1_024 }, "<testsuite name=\"�\"/>"],
|
|
["duplicate-root.xml", { id: "junit", kind: "junit", maxBytes: 1_024 }, "<testsuite/><testsuite/>"],
|
|
["broken.html", { id: "html", kind: "html", maxBytes: 1_024 }, "<!doctype html><html>"],
|
|
["mismatched.html", { id: "html", kind: "html", maxBytes: 1_024 }, "<!doctype html><html><body><main></body></main></html>"],
|
|
["duplicate-root.html", { id: "html", kind: "html", maxBytes: 1_024 }, "<!doctype html><html></html><html></html>"],
|
|
["invalid-root.html", { id: "html", kind: "html", maxBytes: 1_024 }, "<!doctype html><html:report></html:report>"],
|
|
["entity.html", { id: "html", kind: "html", maxBytes: 1_024 }, "<!DOCTYPE html [<!ENTITY x SYSTEM 'file:///etc/passwd'>]><html></html>"],
|
|
["trailing.html", { id: "html", kind: "html", maxBytes: 1_024 }, "<!doctype html><html></html>garbage"],
|
|
] as const)("rejects structurally incomplete %s", async (relative, schema, content) => {
|
|
const root = await temporaryRoot("ci-artifact-structure-");
|
|
await writeArtifact(root, relative, content);
|
|
await expect(
|
|
validateCiArtifact({
|
|
root,
|
|
artifact: artifact(relative, schema.id),
|
|
schema: schema as CiGateArtifactSchema,
|
|
}),
|
|
).rejects.toThrow(/invalid (?:JUnit|HTML) artifact/u);
|
|
});
|
|
|
|
it.each([
|
|
["report.xml", { id: "junit", kind: "junit", maxBytes: 4_096 }, '<testsuite name="safe & A"/>'],
|
|
["report.html", { id: "html", kind: "html", maxBytes: 4_096 }, '<!doctype html><html><body><script>const marker = "<fake>";</script></body></html>'],
|
|
["playwright.html", { id: "html", kind: "html", maxBytes: 4_096 }, '<!doctype html><html><body></body></html><template id="playwrightReportBase64">data:application/zip;base64,AA==</template>'],
|
|
] as const)("accepts bounded, well-formed structured %s evidence", async (relative, schema, content) => {
|
|
const root = await temporaryRoot("ci-artifact-structured-valid-");
|
|
await writeArtifact(root, relative, content);
|
|
await expect(
|
|
validateCiArtifact({
|
|
root,
|
|
artifact: artifact(relative, schema.id),
|
|
schema: schema as CiGateArtifactSchema,
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
["deep.xml", "junit", `<testsuite>${"<group>".repeat(256)}${"</group>".repeat(256)}</testsuite>`],
|
|
["deep.html", "html", `<!doctype html><html>${"<div>".repeat(256)}${"</div>".repeat(256)}</html>`],
|
|
["many.xml", "junit", `<testsuite>${"<testcase/>".repeat(100_000)}</testsuite>`],
|
|
] as const)("rejects parser resource exhaustion in %s", async (relative, kind, content) => {
|
|
const root = await temporaryRoot("ci-artifact-structured-bound-");
|
|
await writeArtifact(root, relative, content);
|
|
await expect(
|
|
validateCiArtifact({
|
|
root,
|
|
artifact: artifact(relative, kind),
|
|
schema: { id: kind, kind, maxBytes: Buffer.byteLength(content) } as CiGateArtifactSchema,
|
|
}),
|
|
).rejects.toThrow(/invalid (?:JUnit|HTML) artifact/u);
|
|
});
|
|
|
|
it("registers a semantic validator for every configured structured artifact", async () => {
|
|
const contract = parseCiGateContract(
|
|
JSON.parse(await readFile("config/ci/gates.json", "utf8")) as unknown,
|
|
);
|
|
expect(
|
|
contract.artifactSchemas.filter(
|
|
(schema) => !hasCiArtifactSemanticValidator(schema),
|
|
),
|
|
).toEqual([]);
|
|
expect(
|
|
contract.artifactSchemas.some(
|
|
(schema) =>
|
|
schema.kind === "json" &&
|
|
(schema.executableSchemaId as string) === "generic-json-object",
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
["automated-a11y", { schemaVersion: 1, automatedStatus: "passed" }],
|
|
["manual-a11y", { schemaVersion: 1, coherentRelease: true, passed: true }],
|
|
["architecture-dependency-report", { staticImportGraph: {} }],
|
|
["design-system-contract", { schemaVersion: 1, mode: "source", checkedTokenCount: 1, failures: ["x"], passed: true }],
|
|
["i18n-contract", { schemaVersion: 1, mode: "source", localeCount: 1, messageKeyCount: 1, checkedFiles: 1, failures: ["x"], passed: true }],
|
|
["diagnostics-contract", { schemaVersion: 1, mode: "source", telemetryEventCount: 1, diagnosticEventCount: 1, checkedFiles: 1, failures: ["x"], passed: true }],
|
|
["realtime-boundaries", { schemaVersion: 1, sourceRoot: "src", violations: [{}], passed: true }],
|
|
["optional-recipes", { schemaVersion: 1, decisionId: "VD-10", violations: ["x"], passed: true }],
|
|
["optional-recipe-fixtures", { schemaVersion: 1, results: [], bundleBudgetFixtures: [], passed: true }],
|
|
["registry-compatibility-fixtures", { schemaVersion: 1, results: [{ id: "x", expected: false, actual: true, passed: true }] }],
|
|
["reproducible-build", { schemaVersion: 1, sourceDateEpoch: "1", buildId: "x", commitSha: "x", releaseId: "x", runnerImage: "x", status: "PASS", firstDigest: "BUILD_FAILED", secondDigest: "BUILD_FAILED", restored: true }],
|
|
["supply-chain-fixtures", { schemaVersion: 1, results: [{ id: "x", passed: true }, { id: "x", passed: true }] }],
|
|
["supply-chain-provider-fixtures", { schemaVersion: 1, fixtures: {}, passingFixtureCount: 1, status: "PASS" }],
|
|
["compatibility-fixtures", { schemaVersion: 1, generatedAt: "2026-08-02T00:00:00.000Z", rules: [], results: [] }],
|
|
["documentation-review", { schemaVersion: 1, passed: true, results: [] }],
|
|
["hosting-headers", { schemaVersion: 1, passed: true, results: [{ passed: false }] }],
|
|
] as const)("rejects pattern-matching but semantically invalid %s JSON", async (executableSchemaId, value) => {
|
|
const root = await temporaryRoot("ci-artifact-semantic-");
|
|
await writeArtifact(root, "report.json", `${JSON.stringify(value)}\n`);
|
|
await expect(
|
|
validateCiArtifact({
|
|
root,
|
|
artifact: artifact("report.json", executableSchemaId),
|
|
schema: {
|
|
id: executableSchemaId,
|
|
kind: "json",
|
|
maxBytes: 32_768,
|
|
executableSchemaId,
|
|
} as CiGateArtifactSchema,
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("fails closed when a regular artifact grows after its bounded lstat", async () => {
|
|
const root = await temporaryRoot("ci-artifact-growth-");
|
|
await writeArtifact(root, "report.txt", "1234");
|
|
const realHandle = await open(path.join(root, "report.txt"), "r");
|
|
await expect(
|
|
readBoundedRegularFile(
|
|
{ root, relativePath: "report.txt", maxBytes: 4 },
|
|
{
|
|
openFile: async () => ({
|
|
stat: async () => realHandle.stat(),
|
|
read: async (buffer, offset) => {
|
|
Buffer.from("12345").copy(buffer, offset);
|
|
return { bytesRead: 5 };
|
|
},
|
|
close: async () => realHandle.close(),
|
|
}),
|
|
},
|
|
),
|
|
).rejects.toThrow(/changed size or exceeds bound/i);
|
|
});
|
|
|
|
it("rejects cross-field tampering in risk coverage evidence", async () => {
|
|
const root = await temporaryRoot("ci-artifact-risk-");
|
|
const risk = {
|
|
schemaVersion: 3,
|
|
policy: "config/testing/risk-coverage.json",
|
|
summary: "artifacts/tests/coverage/coverage-summary.json",
|
|
status: "PASS",
|
|
selectedTotal: 2,
|
|
repositoryTotal: 2,
|
|
counterBearingTotal: 1,
|
|
instrumentedCounterBearingTotal: 1,
|
|
counterlessTotal: 1,
|
|
counterlessModules: ["src/types.ts"],
|
|
preExclusionTotal: 2,
|
|
generatedExclusionCount: 0,
|
|
generatedExclusions: [],
|
|
ownershipScope: "ALL_POLICY_HIGH_RISK",
|
|
ownedHighRiskPaths: ["src/runtime.ts"],
|
|
waivedHighRiskPaths: [],
|
|
uncoveredModules: [],
|
|
results: ["lines", "statements", "functions", "branches"].map((metric) => ({
|
|
scope: "summary",
|
|
metric,
|
|
threshold: 80,
|
|
received: 90,
|
|
passed: true,
|
|
})),
|
|
failures: [],
|
|
};
|
|
const schema = {
|
|
id: "risk",
|
|
kind: "json",
|
|
maxBytes: 16_384,
|
|
executableSchemaId: "risk-coverage-v3",
|
|
} as const;
|
|
await writeArtifact(root, "risk.json", `${JSON.stringify(risk)}\n`);
|
|
await expect(
|
|
validateCiArtifact({ root, artifact: artifact("risk.json", "risk"), schema }),
|
|
).resolves.toBeUndefined();
|
|
await writeArtifact(
|
|
root,
|
|
"risk.json",
|
|
`${JSON.stringify({ ...risk, counterlessTotal: 0 })}\n`,
|
|
);
|
|
await expect(
|
|
validateCiArtifact({ root, artifact: artifact("risk.json", "risk"), schema }),
|
|
).rejects.toThrow(/counter partition|counterless list length/u);
|
|
|
|
for (const [mutation, diagnostic] of [
|
|
[{ preExclusionTotal: 3 }, /pre-exclusion inventory total drift/u],
|
|
[{ status: "PASS", failures: [], results: risk.results.map((entry, index) => index === 0 ? { ...entry, received: 70, passed: false } : entry) }, /status must agree with failures and threshold results/u],
|
|
[{ waivedHighRiskPaths: ["src/runtime.ts"] }, /owned and waived high-risk paths overlap/u],
|
|
[{ results: risk.results.slice(0, 3) }, /all four metrics/u],
|
|
[{ results: [...risk.results, risk.results[0]] }, /duplicated within scope/u],
|
|
[{ results: [] }, /too small|at least 4/iu],
|
|
] as const) {
|
|
await writeArtifact(root, "risk.json", `${JSON.stringify({ ...risk, ...mutation })}\n`);
|
|
await expect(
|
|
validateCiArtifact({ root, artifact: artifact("risk.json", "risk"), schema }),
|
|
).rejects.toThrow(diagnostic);
|
|
}
|
|
});
|
|
|
|
it("rejects coverage counters whose covered and skipped partitions exceed total", async () => {
|
|
const root = await temporaryRoot("ci-artifact-coverage-");
|
|
const counter = { total: 10, covered: 8, skipped: 3, pct: 80 };
|
|
await writeArtifact(
|
|
root,
|
|
"coverage.json",
|
|
`${JSON.stringify({ total: { lines: counter, statements: counter, functions: counter, branches: counter } })}\n`,
|
|
);
|
|
await expect(
|
|
validateCiArtifact({
|
|
root,
|
|
artifact: artifact("coverage.json", "coverage"),
|
|
schema: {
|
|
id: "coverage",
|
|
kind: "json",
|
|
maxBytes: 4_096,
|
|
executableSchemaId: "coverage-summary-v8",
|
|
},
|
|
}),
|
|
).rejects.toThrow(/coverage counter exceeds total/u);
|
|
});
|
|
});
|
|
|
|
describe("candidate archive and provider upload boundaries", () => {
|
|
it("accepts only the manifest-bound candidate member set and bytes", async () => {
|
|
const fixture = await createCandidateArchiveFixture();
|
|
await expect(
|
|
verifyCiCandidateArchive({ archivePath: fixture.archivePath }),
|
|
).resolves.toEqual(
|
|
expect.objectContaining({ archiveSha256: sha256(await readFile(fixture.archivePath)) }),
|
|
);
|
|
});
|
|
|
|
it("rejects an extra candidate member before extraction", async () => {
|
|
const fixture = await createCandidateArchiveFixture({ extraMember: true });
|
|
await expect(
|
|
verifyCiCandidateArchive({ archivePath: fixture.archivePath }),
|
|
).rejects.toThrow(/exact member set drift before extraction/i);
|
|
});
|
|
|
|
it("rejects duplicate archive members before extraction", async () => {
|
|
const fixture = await createCandidateArchiveFixture({ duplicateMember: true });
|
|
await expect(verifyCiCandidateArchive({ archivePath: fixture.archivePath }))
|
|
.rejects.toThrow(/duplicate member/i);
|
|
});
|
|
|
|
it.each(["symlink", "hardlink"] as const)("rejects a %s archive member without touching an outside canary", async (kind) => {
|
|
const root = await temporaryRoot(`ci-candidate-${kind}-`);
|
|
const outside = await temporaryRoot(`ci-candidate-${kind}-outside-`);
|
|
const canary = path.join(outside, "canary");
|
|
await writeFile(canary, "unchanged\n");
|
|
await writeArtifact(root, "target", "target\n");
|
|
if (kind === "symlink") await symlink("target", path.join(root, "unsafe"));
|
|
else await link(path.join(root, "target"), path.join(root, "unsafe"));
|
|
const archivePath = path.join(root, "unsafe.tar.gz");
|
|
const tar = spawnSync("/usr/bin/tar", ["-czf", archivePath, ...(kind === "hardlink" ? ["target"] : []), "unsafe"], { cwd: root, encoding: "utf8" });
|
|
if (tar.status !== 0) throw new Error(tar.stderr);
|
|
await expect(verifyCiCandidateArchive({ archivePath })).rejects.toThrow(/non-regular member/i);
|
|
await expect(readFile(canary, "utf8")).resolves.toBe("unchanged\n");
|
|
});
|
|
|
|
it("rejects traversal members and preserves the outside canary", async () => {
|
|
const root = await temporaryRoot("ci-candidate-traversal-");
|
|
const canary = path.join(root, "outside-canary");
|
|
await writeArtifact(root, "safe", "safe\n");
|
|
await writeFile(canary, "unchanged\n");
|
|
const archivePath = path.join(root, "traversal.tar.gz");
|
|
const tar = spawnSync("/usr/bin/tar", ["-czf", archivePath, "--transform=s|safe|../outside-canary|", "safe"], { cwd: root, encoding: "utf8" });
|
|
if (tar.status !== 0) throw new Error(tar.stderr);
|
|
await expect(verifyCiCandidateArchive({ archivePath })).rejects.toThrow(/unsafe member path/i);
|
|
await expect(readFile(canary, "utf8")).resolves.toBe("unchanged\n");
|
|
});
|
|
|
|
it("rejects an oversized manifest from tar headers before full extraction", async () => {
|
|
const fixture = await createCandidateArchiveFixture({ oversizedManifest: true });
|
|
await expect(verifyCiCandidateArchive({ archivePath: fixture.archivePath }))
|
|
.rejects.toThrow(/manifest exceeds 8388608 bytes/i);
|
|
});
|
|
|
|
it("rejects an expanded-byte bomb before extraction and preserves its canary", async () => {
|
|
const root = await temporaryRoot("ci-candidate-expanded-bomb-");
|
|
const huge = path.join(root, "huge.bin");
|
|
const handle = await open(huge, "w");
|
|
await handle.truncate(268_435_457);
|
|
await handle.close();
|
|
const canary = path.join(root, "canary");
|
|
await writeFile(canary, "unchanged\n");
|
|
const archivePath = path.join(root, "bomb.tar.gz");
|
|
const tar = spawnSync("/usr/bin/tar", ["-czf", archivePath, "huge.bin"], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
timeout: 30_000,
|
|
});
|
|
if (tar.status !== 0) throw new Error(tar.stderr || String(tar.error));
|
|
await expect(verifyCiCandidateArchive({ archivePath }))
|
|
.rejects.toThrow(/expanded bytes exceed the bound/i);
|
|
await expect(readFile(canary, "utf8")).resolves.toBe("unchanged\n");
|
|
}, 40_000);
|
|
|
|
it.each([
|
|
["missing value", ["--archive"]],
|
|
[
|
|
"option-like value",
|
|
["--archive", "missing.tar.gz", "--extract-to", "--github-output", "out"],
|
|
],
|
|
])("maps a %s to the deterministic CLI Usage result", (_label, arguments_) => {
|
|
expect(parseCandidateArchiveArguments(arguments_)).toBeNull();
|
|
expect(CANDIDATE_ARCHIVE_USAGE).toBe(
|
|
"Usage: verify-ci-candidate-archive --archive <path> [--extract-to <path>] [--github-output <path>]\n",
|
|
);
|
|
});
|
|
|
|
it("rejects archive digest mismatch and symlink substitution", async () => {
|
|
const fixture = await createCandidateArchiveFixture();
|
|
await expect(
|
|
verifyCiCandidateArchive({
|
|
archivePath: fixture.archivePath,
|
|
expectedSha256: "0".repeat(64),
|
|
}),
|
|
).rejects.toThrow(/SHA-256 mismatch/u);
|
|
const linked = `${fixture.archivePath}.link`;
|
|
await symlink(path.basename(fixture.archivePath), linked);
|
|
await expect(
|
|
verifyCiCandidateArchive({ archivePath: linked }),
|
|
).rejects.toThrow(/regular non-symlink/u);
|
|
});
|
|
|
|
it("rejects an excessive archive member universe before per-member reads", async () => {
|
|
const fixture = await createCandidateArchiveFixture({ repeatedExtraMembers: 8_200 });
|
|
await expect(
|
|
verifyCiCandidateArchive({ archivePath: fixture.archivePath }),
|
|
).rejects.toThrow(/member count is outside 1\.\.8192|exceeds 8192 members/u);
|
|
});
|
|
|
|
it("uses the captured archive inode when the pathname is replaced mid-verification", async () => {
|
|
const original = await createCandidateArchiveFixture();
|
|
const replacement = await createCandidateArchiveFixture({ extraMember: true });
|
|
const originalArchiveSha256 = sha256(await readFile(original.archivePath));
|
|
const displaced = `${original.archivePath}.displaced`;
|
|
const extractTo = path.join(path.dirname(original.archivePath), "verified-candidate");
|
|
await expect(
|
|
verifyCiCandidateArchive(
|
|
{
|
|
archivePath: original.archivePath,
|
|
extractTo,
|
|
repositoryRoot: path.dirname(original.archivePath),
|
|
},
|
|
{
|
|
afterArchiveRead: async () => {
|
|
await rename(original.archivePath, displaced);
|
|
await rename(replacement.archivePath, original.archivePath);
|
|
},
|
|
},
|
|
),
|
|
).resolves.toEqual(expect.objectContaining({ archiveSha256: originalArchiveSha256 }));
|
|
await expect(readFile(displaced)).resolves.toBeDefined();
|
|
await expect(readFile(path.join(extractTo, "dist/app.js"), "utf8")).resolves.toBe("app\n");
|
|
});
|
|
|
|
it("validates provider JSON against candidate dist and lockfile digests", async () => {
|
|
const fixture = await createProviderFixture();
|
|
await expect(
|
|
validateProviderUpload(providerValidationInput(fixture)),
|
|
).resolves.toEqual(expect.objectContaining({ provider: "fixture" }));
|
|
const report = JSON.parse(await readFile(fixture.reportPath, "utf8")) as Record<string, any>;
|
|
report.candidate.distSha256 = "f".repeat(64);
|
|
report.signature.value = sign(
|
|
null,
|
|
providerEvidenceSignaturePayload(report),
|
|
fixture.privateKey,
|
|
).toString("base64");
|
|
await expect(
|
|
validateProviderUpload(
|
|
providerValidationInput(fixture, Buffer.from(`${JSON.stringify(report)}\n`)),
|
|
),
|
|
).rejects.toThrow(/candidate identity mismatch/i);
|
|
}, 30_000);
|
|
|
|
it("uses the reverified archive manifest when extracted candidate files are mutated", async () => {
|
|
const fixture = await createProviderFixture();
|
|
await writeArtifact(fixture.candidateRoot, "dist/app.js", "mutated\n");
|
|
const mutableManifest = JSON.parse(
|
|
await import("node:fs/promises").then(({ readFile }) =>
|
|
readFile(path.join(fixture.candidateRoot, RELEASE_CANDIDATE_MANIFEST_PATH), "utf8"),
|
|
),
|
|
) as Record<string, unknown>;
|
|
mutableManifest.distSha256 = "e".repeat(64);
|
|
await writeFile(
|
|
path.join(fixture.candidateRoot, RELEASE_CANDIDATE_MANIFEST_PATH),
|
|
`${JSON.stringify(mutableManifest)}\n`,
|
|
);
|
|
await expect(
|
|
validateProviderUpload(providerValidationInput(fixture)),
|
|
).rejects.toThrow(/candidate root changed/i);
|
|
});
|
|
|
|
it("rejects symlinked provider reports at the bounded file boundary", async () => {
|
|
const fixture = await createProviderFixture();
|
|
const real = path.join(fixture.root, "real-report.json");
|
|
await writeFile(real, await readFile(fixture.reportPath));
|
|
await rm(fixture.reportPath);
|
|
await symlink(path.relative(path.dirname(fixture.reportPath), real), fixture.reportPath);
|
|
await expect(
|
|
readBoundedRegularFile({
|
|
root: fixture.root,
|
|
relativePath: path.relative(fixture.root, fixture.reportPath),
|
|
maxBytes: 8_388_608,
|
|
}),
|
|
).rejects.toThrow(/not a regular file/i);
|
|
});
|
|
|
|
it("rejects oversized provider reports before JSON parsing", async () => {
|
|
const fixture = await createProviderFixture();
|
|
await writeFile(fixture.reportPath, Buffer.alloc(8_388_609, 0x20));
|
|
await expect(
|
|
readBoundedRegularFile({
|
|
root: fixture.root,
|
|
relativePath: path.relative(fixture.root, fixture.reportPath),
|
|
maxBytes: 8_388_608,
|
|
}),
|
|
).rejects.toThrow(/size is outside/u);
|
|
});
|
|
|
|
it("rejects a stale raw provider report before starting the provider", async () => {
|
|
const fixture = await createProviderFixture();
|
|
const markerPath = path.join(fixture.root, "provider-started");
|
|
const result = runProviderSupervisor(fixture, {
|
|
command: `node -e 'require("node:fs").writeFileSync(${JSON.stringify(markerPath)}, "started")'`,
|
|
sealedPath: path.join(fixture.root, "provider-evidence/vulnerability-report.json"),
|
|
});
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toMatch(/raw provider report already exists/i);
|
|
await expect(readFile(markerPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it.each(["invalid trust", "invalid archive", "provider failure"] as const)(
|
|
"leaves no stale raw report after %s and permits a clean retry",
|
|
async (failure) => {
|
|
const fixture = await createProviderFixture();
|
|
const archiveBytes = await readFile(fixture.archivePath);
|
|
const publicKeyBytes = await readFile(fixture.publicKeyPath);
|
|
await writeFile(fixture.providerWriter, providerV2WriterSource());
|
|
await rm(fixture.reportPath);
|
|
if (failure === "invalid trust") await writeFile(fixture.publicKeyPath, "not a public key\n");
|
|
if (failure === "invalid archive") await writeFile(fixture.archivePath, "not the captured archive\n");
|
|
const sealedPath = path.join(fixture.root, "provider-evidence/vulnerability-report.json");
|
|
const failed = runProviderSupervisor(fixture, {
|
|
command: failure === "provider failure" ? "exit 9" : `node ${JSON.stringify(fixture.providerWriter)}`,
|
|
sealedPath,
|
|
});
|
|
expect(failed.status).not.toBe(0);
|
|
await expect(lstat(fixture.reportPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
|
|
await writeFile(fixture.archivePath, archiveBytes);
|
|
await writeFile(fixture.publicKeyPath, publicKeyBytes);
|
|
const retried = runProviderSupervisor(fixture, {
|
|
command: `node ${JSON.stringify(fixture.providerWriter)}`,
|
|
sealedPath,
|
|
});
|
|
expect(retried.status, retried.stderr).toBe(0);
|
|
},
|
|
20_000,
|
|
);
|
|
|
|
it("drains and kills provider background processes before sealing evidence", async () => {
|
|
const fixture = await createProviderFixture();
|
|
await rm(fixture.reportPath);
|
|
const sealedPath = path.join(
|
|
fixture.root,
|
|
"provider-evidence/vulnerability-report.json",
|
|
);
|
|
const mutatorMarker = path.join(fixture.root, "background-mutator-ran");
|
|
const providerScript = path.join(fixture.root, "provider.mjs");
|
|
const mutator = [
|
|
"process.on('SIGTERM', () => {});",
|
|
"setTimeout(() => {",
|
|
` require('node:fs').writeFileSync(${JSON.stringify(fixture.protectedCandidatePath)}, 'mutated\\n');`,
|
|
` require('node:fs').writeFileSync(${JSON.stringify(sealedPath)}, '{"mutated":true}\\n');`,
|
|
` require('node:fs').writeFileSync(${JSON.stringify(mutatorMarker)}, 'ran\\n');`,
|
|
"}, 1200);",
|
|
].join("\n");
|
|
await writeFile(
|
|
providerScript,
|
|
[
|
|
"import { spawn } from 'node:child_process';",
|
|
providerV2WriterSource(),
|
|
`const child = spawn(process.execPath, ['-e', ${JSON.stringify(mutator)}], { stdio: 'ignore' });`,
|
|
"child.unref();",
|
|
].join("\n"),
|
|
);
|
|
const result = runProviderSupervisor(fixture, {
|
|
command: `node ${JSON.stringify(providerScript)}`,
|
|
sealedPath,
|
|
});
|
|
expect(result.status, result.stderr).toBe(0);
|
|
await delay(1_500);
|
|
await expect(readFile(fixture.protectedCandidatePath)).resolves.toEqual(
|
|
fixture.protectedCandidateBytes,
|
|
);
|
|
await expect(readFile(mutatorMarker)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(JSON.parse(await readFile(sealedPath, "utf8"))).toEqual(
|
|
expect.objectContaining({ provider: "fixture" }),
|
|
);
|
|
}, 10_000);
|
|
|
|
it("does not expose or mutate a host path outside the sandboxed workspace", async () => {
|
|
const fixture = await createProviderFixture();
|
|
await rm(fixture.reportPath);
|
|
const outside = await temporaryRoot("provider-host-canary-");
|
|
const canary = path.join(outside, "secret-canary");
|
|
await writeFile(canary, "host-secret\n");
|
|
const providerScript = path.join(fixture.root, "provider-host-boundary.mjs");
|
|
await writeFile(providerScript, [
|
|
"import { readFileSync, writeFileSync } from 'node:fs';",
|
|
`try { readFileSync(${JSON.stringify(canary)}); process.exit(9); } catch {}`,
|
|
`try { writeFileSync(${JSON.stringify(canary)}, 'mutated\\n'); } catch {}`,
|
|
providerV2WriterSource({ importFs: false }),
|
|
].join("\n"));
|
|
const result = runProviderSupervisor(fixture, {
|
|
command: `node ${JSON.stringify(providerScript)}`,
|
|
sealedPath: path.join(fixture.root, "provider-evidence/vulnerability-report.json"),
|
|
});
|
|
expect(result.status, result.stderr).toBe(0);
|
|
await expect(readFile(canary, "utf8")).resolves.toBe("host-secret\n");
|
|
}, 10_000);
|
|
|
|
it("applies effective aggregate cgroup limits without exposing command or credentials", async () => {
|
|
const fixture = await createProviderFixture();
|
|
await rm(fixture.reportPath);
|
|
const credential = "live-provider-credential-sentinel";
|
|
const providerScript = path.join(fixture.root, "provider-live-cgroup.mjs");
|
|
await writeFile(providerScript, [
|
|
`if (process.env.VULNERABILITY_PROVIDER_SECRET !== ${JSON.stringify(credential)}) process.exit(9);`,
|
|
`process.stdout.write(${JSON.stringify(credential)});`,
|
|
`process.stderr.write(${JSON.stringify(credential)});`,
|
|
providerV2WriterSource(),
|
|
"setTimeout(() => process.exit(0), 1500);",
|
|
].join("\n"));
|
|
const command = `node ${JSON.stringify(providerScript)}`;
|
|
const execution = startProviderSupervisor(fixture, {
|
|
command,
|
|
sealedPath: path.join(fixture.root, "provider-evidence/vulnerability-report.json"),
|
|
environment: {
|
|
PROVIDER_SUPERVISOR_TIMEOUT_MS: "4000",
|
|
VULNERABILITY_PROVIDER_SECRET: credential,
|
|
},
|
|
});
|
|
const completionStarted = Date.now();
|
|
const unit = await waitForProviderUnit("vulnerability", execution.child.pid);
|
|
const properties = showProviderUnit(unit);
|
|
expect(properties).toMatchObject({
|
|
ActiveState: "active",
|
|
CPUQuotaPerSecUSec: "1s",
|
|
CPUQuotaPeriodUSec: "100ms",
|
|
KillMode: "control-group",
|
|
MemoryMax: "1073741824",
|
|
MemorySwapMax: "0",
|
|
SendSIGKILL: "yes",
|
|
TasksMax: "64",
|
|
});
|
|
const cgroupRoot = path.resolve("/sys/fs/cgroup", `.${properties.ControlGroup}`);
|
|
await expect(readFile(path.join(cgroupRoot, "memory.max"), "utf8")).resolves.toBe("1073741824\n");
|
|
await expect(readFile(path.join(cgroupRoot, "memory.swap.max"), "utf8")).resolves.toBe("0\n");
|
|
await expect(readFile(path.join(cgroupRoot, "pids.max"), "utf8")).resolves.toBe("64\n");
|
|
await expect(readFile(path.join(cgroupRoot, "cpu.max"), "utf8")).resolves.toBe("100000 100000\n");
|
|
expect(readProviderUnitMetadata(unit)).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
|
|
expect(showProcessArguments(execution.child.pid)).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
|
|
const cgroupPids = await readCgroupPids(cgroupRoot);
|
|
const processArguments = cgroupPids.map((pid) => showProcessArguments(pid));
|
|
const directChildPids = await waitForDirectProviderChildren(execution.child.pid!);
|
|
const directChildArguments = directChildPids.map((pid) => showProcessArguments(pid));
|
|
const observedArguments = [
|
|
showProcessArguments(execution.child.pid),
|
|
...directChildArguments,
|
|
...processArguments,
|
|
];
|
|
expect(observedArguments.join("\n")).not.toContain(credential);
|
|
expect(directChildArguments.filter((arguments_) => arguments_.includes("/usr/bin/systemd-run"))).toHaveLength(1);
|
|
expect(directChildArguments.filter((arguments_) => arguments_.includes("provider-raw-guardian.ts"))).toHaveLength(1);
|
|
expect(processArguments.filter((arguments_) => arguments_.includes("provider-scope-wrapper.ts"))).toHaveLength(1);
|
|
expect(processArguments.filter((arguments_) => /\/usr\/bin\/(?:bwrap|prlimit)/u.test(arguments_)).length)
|
|
.toBeGreaterThan(0);
|
|
const infrastructureArguments = [...directChildArguments, ...processArguments].filter((arguments_) =>
|
|
/provider-(?:scope-wrapper|raw-guardian)|systemd-run|bwrap|prlimit/u.test(arguments_),
|
|
);
|
|
expect(infrastructureArguments.join("\n")).not.toContain(command);
|
|
expect(processArguments.filter((arguments_) => arguments_.includes(providerScript))).toHaveLength(1);
|
|
const reportIdentity = await lstat(fixture.reportPath);
|
|
const result = await execution.completion;
|
|
expect(result.code, result.stderr).toBe(0);
|
|
expect(result.stdout).not.toContain(credential);
|
|
expect(result.stderr).not.toContain(credential);
|
|
expect(Date.now() - completionStarted).toBeLessThan(4_000);
|
|
await expectProviderUnitGone(unit);
|
|
await expect(lstat(cgroupRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(cgroupPids.every((pid) => !processExists(pid))).toBe(true);
|
|
expect(directChildPids.every((pid) => !processExists(pid))).toBe(true);
|
|
await expect(lstat(fixture.reportPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
await expect(findProviderRawReferences(fixture.reportPath, reportIdentity)).resolves.toEqual([]);
|
|
}, 10_000);
|
|
|
|
it("kills and collects an active provider when its guardian dies", async () => {
|
|
const fixture = await createProviderFixture();
|
|
await rm(fixture.reportPath);
|
|
const sealedPath = path.join(fixture.root, "provider-evidence/vulnerability-report.json");
|
|
const providerScript = path.join(fixture.root, "provider-active-guardian-death.mjs");
|
|
await writeFile(providerScript, [
|
|
"import { writeFileSync } from 'node:fs';",
|
|
`writeFileSync(${JSON.stringify(fixture.reportPath)}, 'started\\n');`,
|
|
"setInterval(() => {}, 1000);",
|
|
].join("\n"));
|
|
const execution = startProviderSupervisor(fixture, {
|
|
command: `node ${JSON.stringify(providerScript)}`,
|
|
sealedPath,
|
|
environment: { PROVIDER_SUPERVISOR_TIMEOUT_MS: "5000" },
|
|
});
|
|
const unit = await waitForProviderUnit("vulnerability", execution.child.pid);
|
|
await waitForFileContent(fixture.reportPath, "started\n");
|
|
const guardianPid = await waitForDirectChildMatching(
|
|
execution.child.pid!,
|
|
"provider-raw-guardian.ts",
|
|
);
|
|
process.kill(guardianPid, "SIGKILL");
|
|
|
|
const result = await execution.completion;
|
|
expect(result.code).not.toBe(0);
|
|
expect(result.stderr).toMatch(/provider raw guardian failed|provider guardian failed|SIGKILL/iu);
|
|
await expectProviderUnitGone(unit);
|
|
await waitForProcessGone(guardianPid);
|
|
await expect(lstat(fixture.reportPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
await expect(lstat(sealedPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect((await readdir(path.dirname(sealedPath))).filter((leaf) => leaf.includes(".guardian-")))
|
|
.toEqual([]);
|
|
|
|
const retried = runProviderSupervisor(fixture, {
|
|
command: `node ${JSON.stringify(fixture.providerWriter)}`,
|
|
sealedPath,
|
|
});
|
|
expect(retried.status, retried.stderr).toBe(0);
|
|
}, 15_000);
|
|
|
|
it("collects the whole provider scope when its supervisor dies", async () => {
|
|
const fixture = await createProviderFixture();
|
|
await rm(fixture.reportPath);
|
|
const providerScript = path.join(fixture.root, "provider-parent-death.mjs");
|
|
const detachedMarker = path.join(fixture.root, "detached-marker");
|
|
const detachedDescendant = [
|
|
"const {appendFileSync,writeFileSync}=require('node:fs');",
|
|
`setTimeout(()=>appendFileSync(${JSON.stringify(fixture.reportPath)},'detached-survived\\n'),800);`,
|
|
`setTimeout(()=>writeFileSync(${JSON.stringify(detachedMarker)},'survived\\n'),800);`,
|
|
"setInterval(()=>{},1000);",
|
|
].join("\n");
|
|
await writeFile(providerScript, [
|
|
"import { spawn } from 'node:child_process';",
|
|
"import { appendFileSync, writeFileSync } from 'node:fs';",
|
|
`writeFileSync(${JSON.stringify(fixture.reportPath)}, 'started\\n');`,
|
|
`const child=spawn(process.execPath,['-e',${JSON.stringify(detachedDescendant)}],{detached:true,stdio:'ignore'}); child.unref();`,
|
|
`setTimeout(() => appendFileSync(${JSON.stringify(fixture.reportPath)}, 'survived\\n'), 800);`,
|
|
"setInterval(() => {}, 1000);",
|
|
].join("\n"));
|
|
const execution = startProviderSupervisor(fixture, {
|
|
command: `node ${JSON.stringify(providerScript)}`,
|
|
sealedPath: path.join(fixture.root, "provider-evidence/vulnerability-report.json"),
|
|
});
|
|
const unit = await waitForProviderUnit("vulnerability", execution.child.pid);
|
|
await waitForFileContent(fixture.reportPath, "started\n");
|
|
const reportIdentity = await lstat(fixture.reportPath);
|
|
const cgroupRoot = path.resolve("/sys/fs/cgroup", `.${showProviderUnit(unit).ControlGroup}`);
|
|
const cgroupPids = await readCgroupPids(cgroupRoot);
|
|
expect(execution.child.kill("SIGKILL")).toBe(true);
|
|
const result = await execution.completion;
|
|
expect(result.signal).toBe("SIGKILL");
|
|
await expectProviderUnitGone(unit);
|
|
await delay(900);
|
|
await expect(lstat(fixture.reportPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
await expect(lstat(detachedMarker)).rejects.toMatchObject({ code: "ENOENT" });
|
|
await expect(lstat(cgroupRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(cgroupPids.every((pid) => !processExists(pid))).toBe(true);
|
|
await expect(findProviderRawReferences(fixture.reportPath, reportIdentity)).resolves.toEqual([]);
|
|
}, 10_000);
|
|
|
|
it("fails closed when the guardian dies after scope collection and before commit", async () => {
|
|
const fixture = await createProviderFixture();
|
|
await rm(fixture.reportPath);
|
|
await writeFile(fixture.providerWriter, providerV2WriterSource());
|
|
const sealedPath = path.join(fixture.root, "provider-evidence/vulnerability-report.json");
|
|
const outputPipe = path.join(fixture.root, "blocked-github-output");
|
|
const fifo = spawnSync("mkfifo", [outputPipe], { encoding: "utf8" });
|
|
if (fifo.error) throw fifo.error;
|
|
if (fifo.status !== 0) throw new Error(`mkfifo failed: ${fifo.stderr}`);
|
|
const execution = startProviderSupervisor(fixture, {
|
|
command: `node ${JSON.stringify(fixture.providerWriter)}`,
|
|
sealedPath,
|
|
environment: { GITHUB_OUTPUT: outputPipe },
|
|
});
|
|
let completed = false;
|
|
let outputReader: Awaited<ReturnType<typeof open>> | undefined;
|
|
try {
|
|
const unit = await waitForProviderUnit("vulnerability", execution.child.pid);
|
|
await expectProviderUnitGone(unit);
|
|
await waitForExistingPath(sealedPath);
|
|
const guardianPid = await waitForDirectChildMatching(
|
|
execution.child.pid!,
|
|
"provider-raw-guardian.ts",
|
|
);
|
|
expect(showProcessArguments(guardianPid)).not.toContain(fixture.reportPath);
|
|
process.kill(guardianPid, "SIGKILL");
|
|
outputReader = await open(outputPipe, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
const result = await execution.completion;
|
|
completed = true;
|
|
expect(result.code).not.toBe(0);
|
|
expect(result.stderr).toMatch(/provider guardian failed|SIGKILL/iu);
|
|
await waitForProcessGone(guardianPid);
|
|
await waitForMissingPath(fixture.reportPath);
|
|
await waitForMissingPath(sealedPath);
|
|
expect((await readdir(path.dirname(sealedPath))).filter((leaf) => leaf.includes(".guardian-")))
|
|
.toEqual([]);
|
|
} finally {
|
|
await outputReader?.close();
|
|
if (!completed && execution.child.exitCode === null && execution.child.signalCode === null) {
|
|
execution.child.kill("SIGKILL");
|
|
await execution.completion;
|
|
}
|
|
}
|
|
}, 10_000);
|
|
|
|
it("cleans published evidence when the supervisor dies and permits same-workspace retry", async () => {
|
|
const fixture = await createProviderFixture();
|
|
await rm(fixture.reportPath);
|
|
await writeFile(fixture.providerWriter, providerV2WriterSource());
|
|
const sealedPath = path.join(fixture.root, "provider-evidence/vulnerability-report.json");
|
|
const outputPipe = path.join(fixture.root, "supervisor-death-github-output");
|
|
const fifo = spawnSync("mkfifo", [outputPipe], { encoding: "utf8" });
|
|
if (fifo.error) throw fifo.error;
|
|
if (fifo.status !== 0) throw new Error(`mkfifo failed: ${fifo.stderr}`);
|
|
const execution = startProviderSupervisor(fixture, {
|
|
command: `node ${JSON.stringify(fixture.providerWriter)}`,
|
|
sealedPath,
|
|
environment: { GITHUB_OUTPUT: outputPipe },
|
|
});
|
|
let completed = false;
|
|
try {
|
|
const unit = await waitForProviderUnit("vulnerability", execution.child.pid);
|
|
await expectProviderUnitGone(unit);
|
|
await waitForExistingPath(sealedPath);
|
|
const guardianPid = await waitForDirectChildMatching(
|
|
execution.child.pid!,
|
|
"provider-raw-guardian.ts",
|
|
);
|
|
expect(execution.child.kill("SIGKILL")).toBe(true);
|
|
const result = await execution.completion;
|
|
completed = true;
|
|
expect(result.signal).toBe("SIGKILL");
|
|
await waitForProcessGone(guardianPid);
|
|
await waitForMissingPath(fixture.reportPath);
|
|
await waitForMissingPath(sealedPath);
|
|
expect((await readdir(path.dirname(sealedPath))).filter((leaf) => leaf.includes(".guardian-")))
|
|
.toEqual([]);
|
|
|
|
const retried = runProviderSupervisor(fixture, {
|
|
command: `node ${JSON.stringify(fixture.providerWriter)}`,
|
|
sealedPath,
|
|
});
|
|
expect(retried.status, retried.stderr).toBe(0);
|
|
} finally {
|
|
if (!completed && execution.child.exitCode === null && execution.child.signalCode === null) {
|
|
execution.child.kill("SIGKILL");
|
|
await execution.completion;
|
|
}
|
|
}
|
|
}, 15_000);
|
|
|
|
it("observes EMFILE at the provider FD limit and still emits valid evidence", async () => {
|
|
const fixture = await createProviderFixture();
|
|
await rm(fixture.reportPath);
|
|
const adapter = path.join(fixture.root, "provider-fd-limit.mjs");
|
|
await writeFile(adapter, [
|
|
"import { closeSync, openSync } from 'node:fs';",
|
|
"const descriptors = [];",
|
|
"let observed = false;",
|
|
"for (let index=0; index<128; index+=1) { try { descriptors.push(openSync('/dev/null','r')); } catch (error) { if (error?.code === 'EMFILE') observed=true; break; } }",
|
|
"if (!observed) process.exit(9);",
|
|
"for (const descriptor of descriptors) closeSync(descriptor);",
|
|
providerV2WriterSource(),
|
|
].join("\n"));
|
|
const result = runProviderSupervisor(fixture, {
|
|
command: `node ${JSON.stringify(adapter)}`,
|
|
sealedPath: path.join(fixture.root, "provider-evidence/vulnerability-report.json"),
|
|
});
|
|
expect(result.status, result.stderr).toBe(0);
|
|
await expect(lstat(fixture.reportPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
}, 10_000);
|
|
|
|
it("enforces CPU RLIMIT before the independent wall-clock timeout", async () => {
|
|
const fixture = await createProviderFixture();
|
|
await rm(fixture.reportPath);
|
|
const started = Date.now();
|
|
const result = runProviderSupervisor(fixture, {
|
|
command: "node -e 'while(true){}'",
|
|
sealedPath: path.join(fixture.root, "provider-evidence/vulnerability-report.json"),
|
|
environment: { PROVIDER_SUPERVISOR_CPU_SECONDS: "1", PROVIDER_SUPERVISOR_TIMEOUT_MS: "5000" },
|
|
spawnTimeoutMs: 7_000,
|
|
});
|
|
expect(Date.now() - started).toBeLessThan(5_000);
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toMatch(/exit=(?:137|152)|signal=SIG(?:XCPU|KILL)/iu);
|
|
expect(result.stderr).not.toMatch(/timed out/iu);
|
|
await expect(lstat(fixture.reportPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
}, 10_000);
|
|
|
|
it.each(["output", "timeout"] as const)(
|
|
"kills descendants and collects the scope on provider %s termination",
|
|
async (reason) => {
|
|
const fixture = await createProviderFixture();
|
|
await rm(fixture.reportPath);
|
|
const providerScript = path.join(fixture.root, `provider-${reason}-descendant.mjs`);
|
|
const descendant = [
|
|
"const {appendFileSync}=require('node:fs');",
|
|
`setTimeout(()=>appendFileSync(${JSON.stringify(fixture.reportPath)},'survived\\n'),1600);`,
|
|
"setInterval(()=>{},1000);",
|
|
].join("\n");
|
|
await writeFile(providerScript, [
|
|
"import { spawn } from 'node:child_process';",
|
|
"import { writeFileSync } from 'node:fs';",
|
|
`writeFileSync(${JSON.stringify(fixture.reportPath)}, 'started\\n');`,
|
|
`const child=spawn(process.execPath,['-e',${JSON.stringify(descendant)}],{detached:true,stdio:'ignore'}); child.unref();`,
|
|
...(reason === "output"
|
|
? ["setTimeout(()=>{process.stdout.write('o'.repeat(700000));process.stderr.write('e'.repeat(700000));},400);"]
|
|
: []),
|
|
"setInterval(()=>{},1000);",
|
|
].join("\n"));
|
|
const execution = startProviderSupervisor(fixture, {
|
|
command: `node ${JSON.stringify(providerScript)}`,
|
|
sealedPath: path.join(fixture.root, "provider-evidence/vulnerability-report.json"),
|
|
environment: { PROVIDER_SUPERVISOR_TIMEOUT_MS: reason === "timeout" ? "500" : "5000" },
|
|
});
|
|
const unit = await waitForProviderUnit("vulnerability", execution.child.pid);
|
|
await waitForFileContent(fixture.reportPath, "started\n");
|
|
const reportIdentity = await lstat(fixture.reportPath);
|
|
const cgroupRoot = path.resolve("/sys/fs/cgroup", `.${showProviderUnit(unit).ControlGroup}`);
|
|
const cgroupPids = await readCgroupPids(cgroupRoot);
|
|
const result = await execution.completion;
|
|
expect(result.code).not.toBe(0);
|
|
expect(result.stderr).toMatch(reason === "timeout" ? /timed out/iu : /output exceeded/iu);
|
|
expect(Buffer.byteLength(`${result.stdout}${result.stderr}`)).toBeLessThan(1_200_000);
|
|
await expectProviderUnitGone(unit);
|
|
await delay(1_300);
|
|
await expect(lstat(fixture.reportPath)).rejects.toMatchObject({ code: "ENOENT" });
|
|
await expect(lstat(cgroupRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(cgroupPids.every((pid) => !processExists(pid))).toBe(true);
|
|
await expect(findProviderRawReferences(fixture.reportPath, reportIdentity)).resolves.toEqual([]);
|
|
},
|
|
10_000,
|
|
);
|
|
|
|
it.each(["vulnerability", "provenance"] as const)(
|
|
"runs the %s adapter offline and collects its unit",
|
|
async (kind) => {
|
|
const fixture = await createProviderFixture();
|
|
const canary = await startLoopbackCanary(fixture.root);
|
|
try {
|
|
const reportPath = kind === "vulnerability"
|
|
? fixture.reportPath
|
|
: path.join(fixture.root, "provider-evidence/untrusted/provenance-attestation.json");
|
|
await rm(reportPath, { force: true });
|
|
const adapter = path.join(fixture.root, `offline-${kind}-adapter.mjs`);
|
|
await writeFile(adapter, [
|
|
"import { readFileSync, writeFileSync } from 'node:fs';",
|
|
"import { createConnection } from 'node:net';",
|
|
`if (!/0::.*\\/ca-provider-${kind}-[1-9][0-9]*-[0-9a-f]{24}\\.scope(?:\\n|$)/u.test(readFileSync('/proc/self/cgroup','utf8'))) process.exit(9);`,
|
|
`await new Promise((resolve) => { const socket=createConnection({host:'127.0.0.1',port:Number(process.env.${kind.toUpperCase()}_PROVIDER_LOOPBACK_PORT)}); socket.once('connect',()=>process.exit(9)); socket.once('error',()=>resolve()); });`,
|
|
providerV2WriterSource({ kind, importFs: false }),
|
|
].join("\n"));
|
|
const execution = startProviderSupervisor(fixture, {
|
|
kind,
|
|
command: `node ${JSON.stringify(adapter)}`,
|
|
reportPath,
|
|
sealedPath: path.join(
|
|
fixture.root,
|
|
`provider-evidence/${kind === "vulnerability"
|
|
? "vulnerability-report.json"
|
|
: "provenance-attestation.json"}`,
|
|
),
|
|
environment: { [`${kind.toUpperCase()}_PROVIDER_LOOPBACK_PORT`]: String(canary.port) },
|
|
});
|
|
const unit = await waitForProviderUnit(kind, execution.child.pid);
|
|
const result = await execution.completion;
|
|
expect(result.code, result.stderr).toBe(0);
|
|
await expectProviderUnitGone(unit);
|
|
await expect(lstat(canary.marker)).rejects.toMatchObject({ code: "ENOENT" });
|
|
} finally {
|
|
canary.child.kill("SIGKILL");
|
|
await waitForChildClose(canary.child);
|
|
}
|
|
},
|
|
10_000,
|
|
);
|
|
});
|
|
|
|
describe("verified promotion finalizer", () => {
|
|
it("enforces exact private modes even under a fully restrictive umask", async () => {
|
|
const fixture = await createPromotionStagingFixture();
|
|
let previous: number | undefined;
|
|
let finalized: Awaited<ReturnType<typeof stageVerifiedPromotion>>;
|
|
try {
|
|
finalized = await stageVerifiedPromotion(fixture.input, {
|
|
...fixture.dependencies,
|
|
beforePublish: async () => {
|
|
previous = process.umask(0o777);
|
|
},
|
|
});
|
|
} finally {
|
|
if (previous !== undefined) process.umask(previous);
|
|
}
|
|
expect((await lstat(finalized.stagingRoot)).mode & 0o777).toBe(0o700);
|
|
for (const file of finalized.files) {
|
|
expect((await lstat(path.join(finalized.stagingRoot, file.name))).mode & 0o777).toBe(0o400);
|
|
}
|
|
await cleanupFinalizedPromotion(finalizedCleanup(fixture, finalized));
|
|
}, 30_000);
|
|
|
|
it("creates no input records and publishes deterministic exact-five strict v3 bindings", async () => {
|
|
const fixture = await createPromotionStagingFixture();
|
|
let archiveCaptureCount = 0;
|
|
await expect(readFile(path.join(fixture.root, "artifacts/security/provider-verification.json")))
|
|
.rejects.toMatchObject({ code: "ENOENT" });
|
|
await expect(readFile(path.join(fixture.root, "artifacts/security/promotion-verification.json")))
|
|
.rejects.toMatchObject({ code: "ENOENT" });
|
|
|
|
const dependencies = {
|
|
...fixture.dependencies,
|
|
captureArchive: async (input: Parameters<typeof captureCiCandidateArchive>[0]) => {
|
|
archiveCaptureCount += 1;
|
|
return captureCiCandidateArchive(input);
|
|
},
|
|
};
|
|
const finalized = await stageVerifiedPromotion(fixture.input, dependencies);
|
|
expect(archiveCaptureCount).toBe(1);
|
|
expect(finalized.files.map(({ name }) => name)).toEqual(PROMOTED_FILE_NAMES);
|
|
expect((await readdir(finalized.stagingRoot)).sort()).toEqual([...PROMOTED_FILE_NAMES].sort());
|
|
expect(finalized.stagingRoot).toBe(path.join(fixture.runnerTempRoot, fixture.cleanupToken));
|
|
expect(finalized.stagingRoot).not.toContain(".release/promoted-staging");
|
|
expect((await lstat(finalized.stagingRoot)).mode & 0o777).toBe(0o700);
|
|
for (const file of finalized.files) {
|
|
expect((await lstat(path.join(finalized.stagingRoot, file.name))).mode & 0o777).toBe(0o400);
|
|
expect(file.sha256).toBe(sha256(await readFile(path.join(finalized.stagingRoot, file.name))));
|
|
}
|
|
|
|
const providerBytes = await readFile(path.join(finalized.stagingRoot, "provider-verification.json"));
|
|
const promotionBytes = await readFile(path.join(finalized.stagingRoot, "promotion-verification.json"));
|
|
const provider = providerVerificationArtifactSchema.parse(JSON.parse(providerBytes.toString("utf8")));
|
|
const promotion = providerVerificationArtifactSchema.parse(JSON.parse(promotionBytes.toString("utf8")));
|
|
expect(provider.artifactType).toBe("provider-verification");
|
|
expect(promotion.artifactType).toBe("promotion-verification");
|
|
if (provider.artifactType !== "provider-verification" || promotion.artifactType !== "promotion-verification") {
|
|
throw new Error("verification record role narrowing failed");
|
|
}
|
|
expect(provider.status).toBe("PASS");
|
|
expect(promotion.status).toBe("PASS");
|
|
expect(promotion.providerVerificationSha256).toBe(sha256(providerBytes));
|
|
expect(promotion.localEvidenceAssessmentSha256).toBe(fixture.assessmentSha256);
|
|
expect(promotion.run).toEqual(fixture.expectedContext.run);
|
|
expect(promotion.source).toEqual(fixture.expectedContext.source);
|
|
expect(promotion.candidate).toEqual(fixture.expectedContext.candidate);
|
|
expect(promotion.providerEvidence).toEqual(provider.providerEvidence);
|
|
expect(promotion.providerEvidence).toEqual(fixture.expectedProviderEvidence);
|
|
expect(promotion.trustPolicySha256).toBe(provider.trustPolicySha256);
|
|
expect(provider.vulnerabilityStatus).toBe("PASS");
|
|
expect(provider.provenanceAttestationStatus).toBe("PASS");
|
|
|
|
const exactFive = Object.fromEntries(
|
|
await Promise.all(
|
|
PROMOTED_FILE_NAMES.map(async (name) => [
|
|
name,
|
|
await readFile(path.join(finalized.stagingRoot, name)),
|
|
] as const),
|
|
),
|
|
);
|
|
await expect(
|
|
verifyExactPromotionBundle(exactFive, fixture.bundleVerification),
|
|
).resolves.toMatchObject({ status: "PASS" });
|
|
|
|
await cleanupFinalizedPromotion(finalizedCleanup(fixture, finalized));
|
|
const repeated = await stageVerifiedPromotion(fixture.input, dependencies);
|
|
expect(archiveCaptureCount).toBe(2);
|
|
await expect(readFile(path.join(repeated.stagingRoot, "provider-verification.json")))
|
|
.resolves.toEqual(providerBytes);
|
|
await expect(readFile(path.join(repeated.stagingRoot, "promotion-verification.json")))
|
|
.resolves.toEqual(promotionBytes);
|
|
await cleanupFinalizedPromotion(finalizedCleanup(fixture, repeated));
|
|
}, 30_000);
|
|
|
|
it("passes the downstream CLI for a real finalized exact-five with external expected identity", async () => {
|
|
const fixture = await createPromotionStagingFixture({ nowEpochMs: Date.now() });
|
|
const finalized = await stageVerifiedPromotion(fixture.input, fixture.dependencies);
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[path.resolve("scripts/verify-exact-promotion-bundle.ts")],
|
|
{
|
|
cwd: fixture.root,
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
PROMOTION_BUNDLE_ROOT: finalized.stagingRoot,
|
|
VULNERABILITY_PUBLIC_KEY_PATH: fixture.input.vulnerabilityPublicKeyPath,
|
|
VULNERABILITY_KEY_ID: fixture.input.vulnerabilityKeyId,
|
|
PROVENANCE_PUBLIC_KEY_PATH: fixture.input.provenancePublicKeyPath,
|
|
PROVENANCE_KEY_ID: fixture.input.provenanceKeyId,
|
|
EXPECTED_PROMOTION_RUN_ID: fixture.expectedContext.run.id,
|
|
EXPECTED_PROMOTION_RUN_ATTEMPT: String(fixture.expectedContext.run.attempt),
|
|
EXPECTED_PROMOTION_SOURCE_REVISION: fixture.expectedContext.source.revision,
|
|
EXPECTED_PROMOTION_ARCHIVE_SHA256: fixture.expectedContext.candidate.archiveSha256,
|
|
EXPECTED_PROMOTION_SOURCE_SET_SHA256: fixture.expectedContext.source.sourceSetSha256,
|
|
EXPECTED_PROMOTION_BUNDLE_SHA256: fixture.expectedContext.candidate.bundleSha256,
|
|
EXPECTED_PROMOTION_DIST_SHA256: fixture.expectedContext.candidate.distSha256,
|
|
EXPECTED_PROMOTION_LOCKFILE_SHA256: fixture.expectedContext.candidate.lockfileSha256,
|
|
},
|
|
},
|
|
);
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout).toContain("Exact promotion bundle verification: PASS");
|
|
await cleanupFinalizedPromotion(finalizedCleanup(fixture, finalized));
|
|
}, 30_000);
|
|
|
|
it("rejects every exact-five role, subordinate, digest, and shared-context substitution", async () => {
|
|
const fixture = await createPromotionStagingFixture();
|
|
const finalized = await stageVerifiedPromotion(fixture.input, fixture.dependencies);
|
|
const valid = Object.fromEntries(
|
|
await Promise.all(
|
|
PROMOTED_FILE_NAMES.map(async (name) => [
|
|
name,
|
|
await readFile(path.join(finalized.stagingRoot, name)),
|
|
] as const),
|
|
),
|
|
) as Record<(typeof PROMOTED_FILE_NAMES)[number], Buffer>;
|
|
const jsonBytes = (value: unknown) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
|
|
const mutateJson = (
|
|
name: "provider-verification.json" | "promotion-verification.json",
|
|
mutate: (value: Record<string, any>) => void,
|
|
) => {
|
|
const files = { ...valid };
|
|
const value = JSON.parse(files[name].toString("utf8")) as Record<string, any>;
|
|
mutate(value);
|
|
files[name] = jsonBytes(value);
|
|
return files;
|
|
};
|
|
|
|
const providerFail = mutateJson("provider-verification.json", (value) => {
|
|
value.status = "FAIL_UNVERIFIED";
|
|
value.vulnerabilityStatus = "FAIL_UNVERIFIED";
|
|
value.failures = ["fixture provider failure"];
|
|
});
|
|
const arbitraryHash = mutateJson("promotion-verification.json", (value) => {
|
|
value.providerVerificationSha256 = "f".repeat(64);
|
|
});
|
|
const sharedMismatch = mutateJson("promotion-verification.json", (value) => {
|
|
value.run = { ...value.run, id: "other-run" };
|
|
});
|
|
const roleSwap = {
|
|
...valid,
|
|
"provider-verification.json": valid["promotion-verification.json"],
|
|
"promotion-verification.json": valid["provider-verification.json"],
|
|
};
|
|
const reportMismatch = {
|
|
...valid,
|
|
"vulnerability-report.json": Buffer.from("{}\n"),
|
|
};
|
|
const absent = { ...valid } as Partial<typeof valid>;
|
|
delete absent["provider-verification.json"];
|
|
const jointlyRewritten = { ...valid };
|
|
const rewrittenVulnerability = JSON.parse(
|
|
jointlyRewritten["vulnerability-report.json"].toString("utf8"),
|
|
) as Record<string, any>;
|
|
rewrittenVulnerability.provider = "attacker-rewritten-provider";
|
|
jointlyRewritten["vulnerability-report.json"] = jsonBytes(rewrittenVulnerability);
|
|
const rewrittenProvider = JSON.parse(
|
|
jointlyRewritten["provider-verification.json"].toString("utf8"),
|
|
) as Record<string, any>;
|
|
rewrittenProvider.providerEvidence.vulnerabilityReportSha256 = sha256(
|
|
jointlyRewritten["vulnerability-report.json"],
|
|
);
|
|
jointlyRewritten["provider-verification.json"] = jsonBytes(rewrittenProvider);
|
|
const rewrittenPromotion = JSON.parse(
|
|
jointlyRewritten["promotion-verification.json"].toString("utf8"),
|
|
) as Record<string, any>;
|
|
rewrittenPromotion.providerEvidence.vulnerabilityReportSha256 =
|
|
rewrittenProvider.providerEvidence.vulnerabilityReportSha256;
|
|
rewrittenPromotion.providerVerificationSha256 = sha256(
|
|
jointlyRewritten["provider-verification.json"],
|
|
);
|
|
jointlyRewritten["promotion-verification.json"] = jsonBytes(rewrittenPromotion);
|
|
|
|
for (const [label, files, diagnostic] of [
|
|
["provider FAIL", providerFail, /provider verification.*PASS/u],
|
|
["arbitrary provider hash", arbitraryHash, /provider verification.*hash/u],
|
|
["shared context", sharedMismatch, /shared.*run/u],
|
|
["role swap", roleSwap, /artifact role/u],
|
|
["report digest", reportMismatch, /vulnerability report.*digest/u],
|
|
["provider absent", absent, /exact five|missing/u],
|
|
["jointly rewritten invalid signature", jointlyRewritten, /signature.*not PASS|signature verification/u],
|
|
] as const) {
|
|
await expect(
|
|
verifyExactPromotionBundle(files as any, fixture.bundleVerification),
|
|
label,
|
|
).rejects.toThrow(diagnostic);
|
|
}
|
|
|
|
await cleanupFinalizedPromotion(finalizedCleanup(fixture, finalized));
|
|
}, 30_000);
|
|
|
|
it("rejects one Ed25519 key reused for both provider roles", async () => {
|
|
const fixture = await createPromotionStagingFixture();
|
|
await writeFile(
|
|
fixture.input.provenancePublicKeyPath,
|
|
await readFile(fixture.input.vulnerabilityPublicKeyPath),
|
|
);
|
|
expect(fixture.input.provenanceKeyId).not.toBe(fixture.input.vulnerabilityKeyId);
|
|
await expect(
|
|
stageVerifiedPromotion(fixture.input, fixture.dependencies),
|
|
).rejects.toThrow(/distinct|role.*key|same.*key/u);
|
|
await expect(readdir(fixture.runnerTempRoot)).resolves.toEqual([]);
|
|
}, 30_000);
|
|
|
|
it("rejects a correctly re-signed secret scan attestation mismatch in the real finalizer", async () => {
|
|
const fixture = await createPromotionStagingFixture();
|
|
const parsed = JSON.parse(
|
|
await readFile(fixture.input.vulnerabilityReportPath, "utf8"),
|
|
) as Record<string, any>;
|
|
const { signature: currentSignature, ...unsigned } = parsed;
|
|
unsigned.secretScanAttestation = {
|
|
...unsigned.secretScanAttestation,
|
|
sarifSha256: "0".repeat(64),
|
|
};
|
|
const value = sign(
|
|
null,
|
|
providerEvidenceSignaturePayload(unsigned),
|
|
fixture.vulnerabilityPrivateKey,
|
|
).toString("base64");
|
|
await writeFile(
|
|
fixture.input.vulnerabilityReportPath,
|
|
`${JSON.stringify({
|
|
...unsigned,
|
|
signature: { ...currentSignature, value },
|
|
})}\n`,
|
|
);
|
|
|
|
await expect(
|
|
stageVerifiedPromotion(fixture.input, fixture.dependencies),
|
|
).rejects.toThrow(/secret scan attestation mismatch/u);
|
|
await expect(readdir(fixture.runnerTempRoot)).resolves.toEqual([]);
|
|
}, 30_000);
|
|
|
|
it("stages captured archive and reports and validates captured keys after source mutation", async () => {
|
|
const fixture = await createPromotionStagingFixture();
|
|
const finalized = await stageVerifiedPromotion(fixture.input, {
|
|
...fixture.dependencies,
|
|
afterCapture: async () => {
|
|
await Promise.all([
|
|
writeFile(fixture.input.archivePath, "replaced archive\n"),
|
|
writeFile(fixture.input.vulnerabilityReportPath, "replaced vulnerability\n"),
|
|
writeFile(fixture.input.provenanceAttestationPath, "replaced provenance\n"),
|
|
writeFile(fixture.input.vulnerabilityPublicKeyPath, "replaced vulnerability key\n"),
|
|
writeFile(fixture.input.provenancePublicKeyPath, "replaced provenance key\n"),
|
|
]);
|
|
},
|
|
});
|
|
for (const [name, bytes] of fixture.capturedSources) {
|
|
await expect(readFile(path.join(finalized.stagingRoot, name))).resolves.toEqual(bytes);
|
|
}
|
|
await cleanupFinalizedPromotion(finalizedCleanup(fixture, finalized));
|
|
}, 30_000);
|
|
|
|
it.each([
|
|
["archive digest", async (fixture: Awaited<ReturnType<typeof createPromotionStagingFixture>>) => {
|
|
fixture.input = { ...fixture.input, expectedArchiveSha256: "0".repeat(64) };
|
|
}, /archive SHA-256/u],
|
|
["report mutation", async (fixture: Awaited<ReturnType<typeof createPromotionStagingFixture>>) => {
|
|
await writeFile(fixture.input.vulnerabilityReportPath, "mutated report\n");
|
|
}, /invalid|provider/u],
|
|
["key rotation", async (fixture: Awaited<ReturnType<typeof createPromotionStagingFixture>>) => {
|
|
const rotated = generateKeyPairSync("ed25519");
|
|
await writeFile(fixture.input.vulnerabilityPublicKeyPath, rotated.publicKey.export({ type: "spki", format: "pem" }));
|
|
}, /provider evidence failed|trust identity/u],
|
|
["expected nonce replay", async (fixture: Awaited<ReturnType<typeof createPromotionStagingFixture>>) => {
|
|
fixture.input = { ...fixture.input, vulnerabilityInvocationNonce: "9".repeat(64) };
|
|
}, /invocation nonce/u],
|
|
] as const)("rejects %s without staging or PASS records", async (_label, mutate, diagnostic) => {
|
|
const fixture = await createPromotionStagingFixture();
|
|
await mutate(fixture);
|
|
await expect(stageVerifiedPromotion(fixture.input, fixture.dependencies)).rejects.toThrow(diagnostic);
|
|
await expect(readdir(fixture.runnerTempRoot)).resolves.toEqual([]);
|
|
}, 30_000);
|
|
|
|
it("rejects symlinked and oversized captured sources without staging", async () => {
|
|
const linked = await createPromotionStagingFixture();
|
|
const realReport = path.join(linked.root, "real-vulnerability-report.json");
|
|
await writeFile(realReport, await readFile(linked.input.vulnerabilityReportPath));
|
|
await rm(linked.input.vulnerabilityReportPath);
|
|
await symlink(realReport, linked.input.vulnerabilityReportPath);
|
|
await expect(stageVerifiedPromotion(linked.input, linked.dependencies)).rejects.toThrow(/regular file/i);
|
|
await expect(readdir(linked.runnerTempRoot)).resolves.toEqual([]);
|
|
|
|
const oversized = await createPromotionStagingFixture();
|
|
await writeFile(oversized.input.vulnerabilityReportPath, Buffer.alloc(16_777_217, 0x20));
|
|
await expect(stageVerifiedPromotion(oversized.input, oversized.dependencies)).rejects.toThrow(/size is outside/i);
|
|
await expect(readdir(oversized.runnerTempRoot)).resolves.toEqual([]);
|
|
}, 30_000);
|
|
|
|
it("detects a runner-temp parent identity swap and removes its owned partial staging", async () => {
|
|
const fixture = await createPromotionStagingFixture();
|
|
const displaced = `${fixture.runnerTempRoot}-displaced`;
|
|
await expect(stageVerifiedPromotion(fixture.input, {
|
|
...fixture.dependencies,
|
|
afterStagingWrite: async () => {
|
|
await rename(fixture.runnerTempRoot, displaced);
|
|
await mkdir(fixture.runnerTempRoot, { mode: 0o700 });
|
|
},
|
|
})).rejects.toThrow(/parent identity changed/u);
|
|
await expect(readdir(displaced)).resolves.toEqual([]);
|
|
await expect(readdir(fixture.runnerTempRoot)).resolves.toEqual([]);
|
|
}, 30_000);
|
|
|
|
it("pins the staging leaf during writes and rejects a replacement at final visibility", async () => {
|
|
const fixture = await createPromotionStagingFixture();
|
|
const visible = path.join(fixture.runnerTempRoot, fixture.cleanupToken);
|
|
const displaced = `${visible}-displaced`;
|
|
let writes = 0;
|
|
await expect(
|
|
stageVerifiedPromotion(fixture.input, {
|
|
...fixture.dependencies,
|
|
afterFileWrite: async () => {
|
|
writes += 1;
|
|
if (writes === 1) {
|
|
await rename(visible, displaced);
|
|
await mkdir(visible, { mode: 0o700 });
|
|
}
|
|
},
|
|
}),
|
|
).rejects.toThrow(/staging leaf identity changed/u);
|
|
expect(writes).toBe(PROMOTED_FILE_NAMES.length);
|
|
await expect(readdir(displaced)).resolves.toEqual([]);
|
|
await expect(readdir(visible)).resolves.toEqual([]);
|
|
}, 30_000);
|
|
|
|
it("fails and cleans staging when provider evidence expires during staging writes", async () => {
|
|
const fixture = await createPromotionStagingFixture();
|
|
let now = Date.parse("2026-08-02T01:00:00.000Z");
|
|
await expect(
|
|
stageVerifiedPromotion(fixture.input, {
|
|
...fixture.dependencies,
|
|
nowEpochMs: () => now,
|
|
beforeSeal: async () => {
|
|
now = Date.parse("2026-08-02T02:00:00.000Z");
|
|
},
|
|
}),
|
|
).rejects.toThrow(/expired|freshness.*not PASS/u);
|
|
await expect(readdir(fixture.runnerTempRoot)).resolves.toEqual([]);
|
|
}, 30_000);
|
|
|
|
it("cleanup is token-bound and removes only the finalized private directory", async () => {
|
|
const fixture = await createPromotionStagingFixture();
|
|
const finalized = await stageVerifiedPromotion(fixture.input, fixture.dependencies);
|
|
const canary = path.join(fixture.runnerTempRoot, "canary");
|
|
await writeFile(canary, "unchanged\n");
|
|
await expect(cleanupFinalizedPromotion({
|
|
runnerTempRoot: fixture.runnerTempRoot,
|
|
stagingRoot: finalized.stagingRoot,
|
|
cleanupToken: `${finalized.cleanupToken}-wrong`,
|
|
runnerTempIdentity: finalized.runnerTempIdentity,
|
|
stagingIdentity: finalized.stagingIdentity,
|
|
})).rejects.toThrow(/root\/token mismatch/u);
|
|
await cleanupFinalizedPromotion(finalizedCleanup(fixture, finalized));
|
|
await expect(readFile(canary, "utf8")).resolves.toBe("unchanged\n");
|
|
await expect(lstat(finalized.stagingRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
|
}, 30_000);
|
|
|
|
it("cleanup rejects a swapped runner-temp ancestor and a symlinked staging leaf", async () => {
|
|
const swapped = await createPromotionStagingFixture();
|
|
const finalized = await stageVerifiedPromotion(swapped.input, swapped.dependencies);
|
|
const displaced = `${swapped.runnerTempRoot}-cleanup-displaced`;
|
|
await expect(cleanupFinalizedPromotion(finalizedCleanup(swapped, finalized), {
|
|
beforeRemove: async () => {
|
|
await rename(swapped.runnerTempRoot, displaced);
|
|
await mkdir(swapped.runnerTempRoot, { mode: 0o700 });
|
|
},
|
|
})).rejects.toThrow(/parent identity changed/u);
|
|
await expect(readdir(swapped.runnerTempRoot)).resolves.toEqual([]);
|
|
await expect(readdir(displaced)).resolves.toEqual([finalized.cleanupToken]);
|
|
|
|
const linked = await createPromotionStagingFixture();
|
|
const linkedFinalized = await stageVerifiedPromotion(linked.input, linked.dependencies);
|
|
const saved = `${linkedFinalized.stagingRoot}-saved`;
|
|
const outside = await temporaryRoot("promotion-cleanup-outside-");
|
|
const canary = path.join(outside, "canary");
|
|
await writeFile(canary, "unchanged\n");
|
|
await expect(
|
|
cleanupFinalizedPromotion(finalizedCleanup(linked, linkedFinalized), {
|
|
beforeRemove: async () => {
|
|
await rename(linkedFinalized.stagingRoot, saved);
|
|
await symlink(outside, linkedFinalized.stagingRoot);
|
|
},
|
|
}),
|
|
).rejects.toThrow(/leaf.*identity|staging leaf/u);
|
|
await expect(readFile(canary, "utf8")).resolves.toBe("unchanged\n");
|
|
await expect(readdir(saved)).resolves.toEqual([]);
|
|
}, 30_000);
|
|
|
|
it("never deletes an unrelated leaf substituted after cleanup validation", async () => {
|
|
const fixture = await createPromotionStagingFixture();
|
|
const finalized = await stageVerifiedPromotion(fixture.input, fixture.dependencies);
|
|
const displaced = `${finalized.stagingRoot}-owned`;
|
|
const canary = path.join(finalized.stagingRoot, "unrelated-canary");
|
|
|
|
await expect(cleanupFinalizedPromotion(finalizedCleanup(fixture, finalized), {
|
|
beforeRemove: async () => {
|
|
await rename(finalized.stagingRoot, displaced);
|
|
await mkdir(finalized.stagingRoot, { mode: 0o700 });
|
|
await writeFile(canary, "must survive\n");
|
|
},
|
|
})).rejects.toThrow(/identity changed|substituted/u);
|
|
await expect(readFile(canary, "utf8")).resolves.toBe("must survive\n");
|
|
await expect(readdir(displaced)).resolves.toEqual(
|
|
expect.arrayContaining([...PROMOTED_FILE_NAMES]),
|
|
);
|
|
}, 30_000);
|
|
|
|
});
|
|
|
|
type ProviderSupervisorInput = Readonly<{
|
|
kind?: "vulnerability" | "provenance";
|
|
command: string;
|
|
reportPath?: string;
|
|
sealedPath: string;
|
|
environment?: Readonly<Record<string, string>>;
|
|
spawnTimeoutMs?: number;
|
|
maxBuffer?: number;
|
|
}>;
|
|
|
|
function runProviderSupervisor(
|
|
fixture: Awaited<ReturnType<typeof createProviderFixture>>,
|
|
input: ProviderSupervisorInput,
|
|
) {
|
|
const kind = input.kind ?? "vulnerability";
|
|
return spawnSync(
|
|
process.execPath,
|
|
[path.resolve("scripts/run-and-validate-provider.ts"), "--kind", kind],
|
|
{
|
|
cwd: fixture.root,
|
|
encoding: "utf8",
|
|
timeout: input.spawnTimeoutMs ?? 8_000,
|
|
maxBuffer: input.maxBuffer,
|
|
env: providerSupervisorEnvironment(fixture, input),
|
|
},
|
|
);
|
|
}
|
|
|
|
function startProviderSupervisor(
|
|
fixture: Awaited<ReturnType<typeof createProviderFixture>>,
|
|
input: ProviderSupervisorInput,
|
|
): Readonly<{
|
|
child: ChildProcess;
|
|
completion: Promise<Readonly<{
|
|
code: number | null;
|
|
signal: NodeJS.Signals | null;
|
|
stderr: string;
|
|
stdout: string;
|
|
}>>;
|
|
}> {
|
|
const kind = input.kind ?? "vulnerability";
|
|
const child = spawn(
|
|
process.execPath,
|
|
[path.resolve("scripts/run-and-validate-provider.ts"), "--kind", kind],
|
|
{
|
|
cwd: fixture.root,
|
|
env: providerSupervisorEnvironment(fixture, input),
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
},
|
|
);
|
|
let stderr = "";
|
|
let stdout = "";
|
|
child.stderr?.setEncoding("utf8");
|
|
child.stdout?.setEncoding("utf8");
|
|
child.stderr?.on("data", (chunk: string) => { stderr += chunk; });
|
|
child.stdout?.on("data", (chunk: string) => { stdout += chunk; });
|
|
const completion = new Promise<Readonly<{
|
|
code: number | null;
|
|
signal: NodeJS.Signals | null;
|
|
stderr: string;
|
|
stdout: string;
|
|
}>>((resolve, reject) => {
|
|
child.once("error", reject);
|
|
child.once("close", (code, signal) => resolve({ code, signal, stderr, stdout }));
|
|
});
|
|
return Object.freeze({ child, completion });
|
|
}
|
|
|
|
function providerSupervisorEnvironment(
|
|
fixture: Awaited<ReturnType<typeof createProviderFixture>>,
|
|
input: ProviderSupervisorInput,
|
|
): NodeJS.ProcessEnv {
|
|
const kind = input.kind ?? "vulnerability";
|
|
const reportPath = input.reportPath ?? fixture.reportPath;
|
|
return {
|
|
...process.env,
|
|
...input.environment,
|
|
...(kind === "vulnerability"
|
|
? {
|
|
VULNERABILITY_PROVIDER_COMMAND: input.command,
|
|
VULNERABILITY_PROVIDER_PRIVATE_KEY_PATH: fixture.privateKeyPath,
|
|
VULNERABILITY_PUBLIC_KEY_PATH: fixture.publicKeyPath,
|
|
VULNERABILITY_KEY_ID: fixture.keyId,
|
|
VULNERABILITY_REPORT_PATH: reportPath,
|
|
}
|
|
: {
|
|
PROVENANCE_PROVIDER_COMMAND: input.command,
|
|
PROVENANCE_PROVIDER_PRIVATE_KEY_PATH: fixture.privateKeyPath,
|
|
PROVENANCE_PUBLIC_KEY_PATH: fixture.publicKeyPath,
|
|
PROVENANCE_KEY_ID: fixture.keyId,
|
|
PROVENANCE_ATTESTATION_PATH: reportPath,
|
|
}),
|
|
VALIDATED_PROVIDER_REPORT_PATH: input.sealedPath,
|
|
CANDIDATE_ARCHIVE_PATH: fixture.archivePath,
|
|
CANDIDATE_ARCHIVE_SHA256: fixture.archiveSha256,
|
|
CI_RUN_ID: fixture.expectedContext.run.id,
|
|
CI_RUN_ATTEMPT: String(fixture.expectedContext.run.attempt),
|
|
EXPECTED_SOURCE_REVISION: fixture.expectedContext.source.revision,
|
|
};
|
|
}
|
|
|
|
async function waitForProviderUnit(
|
|
kind: "vulnerability" | "provenance",
|
|
supervisorPid: number | undefined,
|
|
): Promise<string> {
|
|
if (!supervisorPid) throw new Error("provider supervisor did not expose its PID");
|
|
const pattern = `ca-provider-${kind}-${supervisorPid}-*.scope`;
|
|
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
const units = listProviderUnits(pattern);
|
|
if (units.length === 1) return units[0]!;
|
|
if (units.length > 1) throw new Error(`provider unit identity is ambiguous: ${units.join(", ")}`);
|
|
await delay(25);
|
|
}
|
|
throw new Error(`provider cgroup unit did not become active: ${pattern}`);
|
|
}
|
|
|
|
async function expectProviderUnitGone(unitName: string): Promise<void> {
|
|
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
if (listProviderUnits(unitName).length === 0) return;
|
|
await delay(25);
|
|
}
|
|
throw new Error(`provider scope survived completion: ${unitName}`);
|
|
}
|
|
|
|
async function waitForFileContent(filename: string, expected: string): Promise<void> {
|
|
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
try {
|
|
if ((await readFile(filename, "utf8")) === expected) return;
|
|
} catch (error) {
|
|
if (!hasErrorCode(error, "ENOENT")) throw error;
|
|
}
|
|
await delay(25);
|
|
}
|
|
throw new Error(`provider output did not reach expected content: ${filename}`);
|
|
}
|
|
|
|
async function readCgroupPids(cgroupRoot: string): Promise<number[]> {
|
|
return (await readFile(path.join(cgroupRoot, "cgroup.procs"), "utf8"))
|
|
.trim().split("\n").filter(Boolean).map(Number);
|
|
}
|
|
|
|
async function waitForDirectProviderChildren(supervisorPid: number): Promise<number[]> {
|
|
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
const childrenPath = `/proc/${supervisorPid}/task/${supervisorPid}/children`;
|
|
const children = (await readFile(childrenPath, "utf8"))
|
|
.trim().split(/\s+/u).filter(Boolean).map(Number);
|
|
const arguments_ = children.flatMap((pid) => {
|
|
try {
|
|
return [showProcessArguments(pid)];
|
|
} catch (error) {
|
|
if (!processExists(pid)) return [];
|
|
throw error;
|
|
}
|
|
});
|
|
if (
|
|
arguments_.some((value) => value.includes("/usr/bin/systemd-run")) &&
|
|
arguments_.some((value) => value.includes("provider-raw-guardian.ts"))
|
|
) {
|
|
return children;
|
|
}
|
|
await delay(25);
|
|
}
|
|
throw new Error("provider supervisor children were not simultaneously observable");
|
|
}
|
|
|
|
async function waitForDirectChildMatching(supervisorPid: number, pattern: string): Promise<number> {
|
|
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
const children = (await readFile(`/proc/${supervisorPid}/task/${supervisorPid}/children`, "utf8"))
|
|
.trim().split(/\s+/u).filter(Boolean).map(Number);
|
|
for (const pid of children) {
|
|
if (processExists(pid) && showProcessArguments(pid).includes(pattern)) return pid;
|
|
}
|
|
await delay(25);
|
|
}
|
|
throw new Error(`provider supervisor child was not observable: ${pattern}`);
|
|
}
|
|
|
|
async function waitForExistingPath(target: string): Promise<void> {
|
|
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
try {
|
|
await lstat(target);
|
|
return;
|
|
} catch (error) {
|
|
if (!hasErrorCode(error, "ENOENT")) throw error;
|
|
}
|
|
await delay(25);
|
|
}
|
|
throw new Error(`expected path did not appear: ${target}`);
|
|
}
|
|
|
|
async function waitForMissingPath(target: string): Promise<void> {
|
|
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
try {
|
|
await lstat(target);
|
|
} catch (error) {
|
|
if (hasErrorCode(error, "ENOENT")) return;
|
|
throw error;
|
|
}
|
|
await delay(25);
|
|
}
|
|
throw new Error(`expected path survived: ${target}`);
|
|
}
|
|
|
|
async function waitForProcessGone(pid: number): Promise<void> {
|
|
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
if (!processExists(pid)) return;
|
|
await delay(25);
|
|
}
|
|
throw new Error(`expected process survived: ${pid}`);
|
|
}
|
|
|
|
async function findProviderRawReferences(
|
|
reportPath: string,
|
|
identity: Readonly<{ dev: number; ino: number }>,
|
|
): Promise<string[]> {
|
|
const references: string[] = [];
|
|
const processDirectories = (await readdir("/proc", { withFileTypes: true }))
|
|
.filter((entry) => entry.isDirectory() && /^[1-9][0-9]*$/u.test(entry.name))
|
|
.map((entry) => entry.name);
|
|
for (const pid of processDirectories) {
|
|
let descriptors: string[];
|
|
try {
|
|
descriptors = await readdir(`/proc/${pid}/fd`);
|
|
} catch (error) {
|
|
if (isTransientProcError(error)) continue;
|
|
throw error;
|
|
}
|
|
for (const descriptor of descriptors) {
|
|
try {
|
|
const metadata = await stat(`/proc/${pid}/fd/${descriptor}`);
|
|
if (metadata.dev === identity.dev && metadata.ino === identity.ino) {
|
|
references.push(`${pid}/fd/${descriptor}`);
|
|
}
|
|
} catch (error) {
|
|
if (!isTransientProcError(error)) throw error;
|
|
}
|
|
}
|
|
try {
|
|
const mountInfo = await readFile(`/proc/${pid}/mountinfo`, "utf8");
|
|
if (mountInfo.includes(reportPath)) references.push(`${pid}/mountinfo`);
|
|
} catch (error) {
|
|
if (!isTransientProcError(error)) throw error;
|
|
}
|
|
}
|
|
return references.sort();
|
|
}
|
|
|
|
function isTransientProcError(error: unknown): boolean {
|
|
return hasErrorCode(error, "EACCES") || hasErrorCode(error, "ENOENT") || hasErrorCode(error, "EPERM");
|
|
}
|
|
|
|
function processExists(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
if (hasErrorCode(error, "ESRCH")) return false;
|
|
if (hasErrorCode(error, "EPERM")) return true;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function listProviderUnits(pattern: string): string[] {
|
|
const result = spawnSync(
|
|
"/usr/bin/systemctl",
|
|
["--user", "list-units", "--all", "--type=scope", "--plain", "--no-legend", pattern],
|
|
{ encoding: "utf8", maxBuffer: 65_536, timeout: 2_000 },
|
|
);
|
|
if (result.error) throw result.error;
|
|
if (result.status !== 0) throw new Error(`systemctl list-units failed: ${result.stderr.trim()}`);
|
|
return result.stdout
|
|
.split("\n")
|
|
.map((line) => line.trim().split(/\s+/u)[0] ?? "")
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function showProviderUnit(unitName: string): Readonly<Record<string, string>> {
|
|
const properties = [
|
|
"ActiveState", "ControlGroup", "CPUQuotaPerSecUSec", "CPUQuotaPeriodUSec",
|
|
"KillMode", "MemoryMax", "MemorySwapMax", "SendSIGKILL", "TasksMax",
|
|
];
|
|
const result = spawnSync(
|
|
"/usr/bin/systemctl",
|
|
["--user", "show", unitName, ...properties.map((name) => `--property=${name}`)],
|
|
{ encoding: "utf8", maxBuffer: 65_536, timeout: 2_000 },
|
|
);
|
|
if (result.error) throw result.error;
|
|
if (result.status !== 0) throw new Error(`systemctl show failed: ${result.stderr.trim()}`);
|
|
return Object.freeze(Object.fromEntries(result.stdout.trim().split("\n").map((line) => {
|
|
const separator = line.indexOf("=");
|
|
return [line.slice(0, separator), line.slice(separator + 1)];
|
|
})));
|
|
}
|
|
|
|
function readProviderUnitMetadata(unitName: string): string {
|
|
const result = spawnSync(
|
|
"/usr/bin/systemctl",
|
|
["--user", "show", unitName],
|
|
{ encoding: "utf8", maxBuffer: 262_144, timeout: 2_000 },
|
|
);
|
|
if (result.error) throw result.error;
|
|
if (result.status !== 0) throw new Error(`systemctl show failed: ${result.stderr.trim()}`);
|
|
return result.stdout;
|
|
}
|
|
|
|
function showProcessArguments(pid: number | undefined): string {
|
|
if (!pid) throw new Error("provider supervisor did not expose its PID");
|
|
const result = spawnSync(
|
|
"/usr/bin/ps",
|
|
["-o", "args=", "-p", String(pid)],
|
|
{ encoding: "utf8", maxBuffer: 65_536, timeout: 2_000 },
|
|
);
|
|
if (result.error) throw result.error;
|
|
if (result.status !== 0) throw new Error(`ps failed: ${result.stderr.trim()}`);
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
async function startLoopbackCanary(root: string): Promise<Readonly<{
|
|
child: ChildProcess;
|
|
marker: string;
|
|
port: number;
|
|
}>> {
|
|
const portFile = path.join(root, `loopback-port-${Date.now()}`);
|
|
const marker = `${portFile}-reached`;
|
|
const child = spawn(process.execPath, ["-e", [
|
|
"const {createServer}=require('node:http');",
|
|
"const {writeFileSync}=require('node:fs');",
|
|
`const server=createServer((_request,response)=>{writeFileSync(${JSON.stringify(marker)},'reached\\n');response.end('ok');});`,
|
|
`server.listen(0,'127.0.0.1',()=>writeFileSync(${JSON.stringify(portFile)},String(server.address().port)));`,
|
|
].join("\n")], { stdio: "ignore" });
|
|
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
try {
|
|
const port = Number(await readFile(portFile, "utf8"));
|
|
if (Number.isInteger(port) && port > 0) return Object.freeze({ child, marker, port });
|
|
} catch (error) {
|
|
if (!hasErrorCode(error, "ENOENT")) throw error;
|
|
}
|
|
await delay(25);
|
|
}
|
|
child.kill("SIGKILL");
|
|
await waitForChildClose(child);
|
|
throw new Error("loopback canary did not become ready");
|
|
}
|
|
|
|
async function waitForChildClose(child: ChildProcess): Promise<void> {
|
|
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
await new Promise<void>((resolve) => child.once("close", () => resolve()));
|
|
}
|
|
|
|
function hasErrorCode(error: unknown, code: string): boolean {
|
|
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
|
}
|
|
|
|
function providerValidationInput(
|
|
fixture: Awaited<ReturnType<typeof createProviderFixture>>,
|
|
capturedReport: Buffer = fixture.reportBytes,
|
|
) {
|
|
return {
|
|
kind: "vulnerability" as const,
|
|
verifiedManifest: fixture.candidate,
|
|
archiveSha256: fixture.archiveSha256,
|
|
candidateRoot: fixture.candidateRoot,
|
|
capturedReport,
|
|
expectedContext: fixture.expectedContext,
|
|
trust: fixture.trust,
|
|
nowEpochMs: () => fixture.now,
|
|
};
|
|
}
|
|
|
|
function providerV2WriterSource(
|
|
options: Readonly<{
|
|
importFs?: boolean;
|
|
kind?: "vulnerability" | "provenance";
|
|
}> = {},
|
|
): string {
|
|
const providerKind = options.kind ?? "vulnerability";
|
|
const privateKeyEnvironment = providerKind === "vulnerability"
|
|
? "VULNERABILITY_PROVIDER_PRIVATE_KEY_PATH"
|
|
: "PROVENANCE_PROVIDER_PRIVATE_KEY_PATH";
|
|
const reportEnvironment = providerKind === "vulnerability"
|
|
? "VULNERABILITY_REPORT_PATH"
|
|
: "PROVENANCE_ATTESTATION_PATH";
|
|
return [
|
|
"import { createPrivateKey, sign } from 'node:crypto';",
|
|
...(options.importFs === false
|
|
? []
|
|
: ["import { readFileSync, writeFileSync } from 'node:fs';"]),
|
|
"const canonical = (value) => {",
|
|
" if (Array.isArray(value)) return value.map(canonical).sort((left, right) => String(JSON.stringify(left)).localeCompare(String(JSON.stringify(right))));",
|
|
" if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonical(item)]));",
|
|
" return value;",
|
|
"};",
|
|
"const unsigned = {",
|
|
" schemaVersion: Number(process.env.PROVIDER_EVIDENCE_SCHEMA_VERSION),",
|
|
" evidenceType: process.env.PROVIDER_EVIDENCE_TYPE,",
|
|
" provider: 'fixture',",
|
|
" issuedAt: process.env.PROVIDER_ISSUED_AT,",
|
|
" expiresAt: process.env.PROVIDER_EXPIRES_AT,",
|
|
" run: { id: process.env.CI_RUN_ID, attempt: Number(process.env.CI_RUN_ATTEMPT), invocationNonce: process.env.PROVIDER_INVOCATION_NONCE },",
|
|
" source: { revision: process.env.SOURCE_REVISION, sourceSetSha256: process.env.SOURCE_SET_SHA256 },",
|
|
" candidate: { archiveSha256: process.env.CANDIDATE_ARCHIVE_SHA256, bundleSha256: process.env.CANDIDATE_BUNDLE_SHA256, distSha256: process.env.CANDIDATE_DIST_SHA256, lockfileSha256: process.env.CANDIDATE_LOCKFILE_SHA256 },",
|
|
...(providerKind === "vulnerability"
|
|
? [
|
|
" secretScanAttestation: { status: process.env.SECRET_SCAN_STATUS, localEvidenceAssessmentSha256: process.env.SECRET_SCAN_LOCAL_EVIDENCE_ASSESSMENT_SHA256, sourceSetSha256: process.env.SECRET_SCAN_SOURCE_SET_SHA256, policySha256: process.env.SECRET_SCAN_POLICY_SHA256, sarifSha256: process.env.SECRET_SCAN_SARIF_SHA256, scanInputSha256: process.env.SECRET_SCAN_INPUT_SHA256 },",
|
|
" findings: [],",
|
|
]
|
|
: [
|
|
" signer: 'fixture-signer',",
|
|
" subject: { name: 'dist', digest: { sha256: process.env.CANDIDATE_DIST_SHA256 } },",
|
|
]),
|
|
"};",
|
|
`const privateKey = createPrivateKey(readFileSync(process.env.${privateKeyEnvironment}));`,
|
|
"const value = sign(null, Buffer.from(JSON.stringify(canonical(unsigned))), privateKey).toString('base64');",
|
|
`writeFileSync(process.env.${reportEnvironment}, \`\${JSON.stringify({ ...unsigned, signature: { algorithm: 'Ed25519', keyId: process.env.PROVIDER_KEY_ID, publicKeyFingerprint: process.env.PROVIDER_PUBLIC_KEY_FINGERPRINT, value } })}\\n\`);`,
|
|
].join("\n");
|
|
}
|
|
|
|
async function createCandidateArchiveFixture(
|
|
options: Readonly<{ extraMember?: boolean; repeatedExtraMembers?: number; duplicateMember?: boolean; oversizedManifest?: boolean }> = {},
|
|
): Promise<Readonly<{ root: string; archivePath: string }>> {
|
|
const root = await temporaryRoot("ci-candidate-archive-");
|
|
const files = new Map<string, Buffer>();
|
|
files.set("dist/app.js", Buffer.from("app\n"));
|
|
for (const evidencePath of RELEASE_CANDIDATE_EVIDENCE_PATHS) {
|
|
files.set(evidencePath, Buffer.from(`${evidencePath}\n`));
|
|
}
|
|
for (const [relative, content] of files) await writeArtifact(root, relative, content);
|
|
await writeArtifact(
|
|
root,
|
|
"artifacts/release/dependency-inventory.json",
|
|
`${JSON.stringify({ lockfileSha256: sha256(files.get("pnpm-lock.yaml")!) })}\n`,
|
|
);
|
|
const manifest = await createReleaseCandidateManifest(root);
|
|
await writeArtifact(
|
|
root,
|
|
RELEASE_CANDIDATE_MANIFEST_PATH,
|
|
`${JSON.stringify(manifest)}${options.oversizedManifest ? " ".repeat(8_388_609) : "\n"}`,
|
|
);
|
|
if (options.extraMember || options.repeatedExtraMembers) {
|
|
await writeArtifact(root, "extra.txt", "extra\n");
|
|
}
|
|
const archivePath = path.join(root, "candidate.tar.gz");
|
|
const members = [
|
|
"dist",
|
|
...RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
|
RELEASE_CANDIDATE_MANIFEST_PATH,
|
|
...(options.duplicateMember ? ["pnpm-lock.yaml"] : []),
|
|
...(options.extraMember ? ["extra.txt"] : []),
|
|
...Array.from({ length: options.repeatedExtraMembers ?? 0 }, () => "extra.txt"),
|
|
];
|
|
const tar = spawnSync("tar", [...(options.duplicateMember ? ["--hard-dereference"] : []), "-czf", archivePath, ...members], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
});
|
|
if (tar.status !== 0) throw new Error(tar.stderr);
|
|
return { root, archivePath };
|
|
}
|
|
|
|
async function createProviderFixture() {
|
|
const base = await ensureProviderBaseFixture();
|
|
const root = await temporaryRoot("ci-provider-upload-");
|
|
await cp(base, root, { recursive: true });
|
|
const candidateRoot = root;
|
|
const candidate = JSON.parse(
|
|
await readFile(path.join(root, RELEASE_CANDIDATE_MANIFEST_PATH), "utf8"),
|
|
) as Awaited<ReturnType<typeof createReleaseCandidateManifest>>;
|
|
const protectedCandidateRelative = candidate.files.find((file) =>
|
|
file.path.startsWith("dist/"),
|
|
)?.path;
|
|
if (!protectedCandidateRelative) {
|
|
throw new Error("provider fixture candidate has no dist file");
|
|
}
|
|
const protectedCandidatePath = path.join(root, protectedCandidateRelative);
|
|
const protectedCandidateBytes = await readFile(protectedCandidatePath);
|
|
const assessment = localEvidenceAssessmentArtifactSchema.parse(
|
|
JSON.parse(
|
|
await readFile(path.join(root, LOCAL_EVIDENCE_ASSESSMENT_PATH), "utf8"),
|
|
) as unknown,
|
|
);
|
|
const archivePath = path.join(root, "candidate.tar.gz");
|
|
const archiveSha256 = sha256(await readFile(archivePath));
|
|
const keys = generateKeyPairSync("ed25519");
|
|
const keyId = "fixture-vulnerability-key";
|
|
const publicKeyPath = path.join(root, "keys/vulnerability.pem");
|
|
const privateKeyPath = path.join(root, "keys/vulnerability-private.pem");
|
|
await writeArtifact(
|
|
root,
|
|
"keys/vulnerability.pem",
|
|
keys.publicKey.export({ type: "spki", format: "pem" }),
|
|
);
|
|
await writeArtifact(
|
|
root,
|
|
"keys/vulnerability-private.pem",
|
|
keys.privateKey.export({ type: "pkcs8", format: "pem" }),
|
|
);
|
|
const trust = (await readProviderTrust(root, publicKeyPath, keyId))!;
|
|
const now = Date.parse("2026-08-02T01:00:00.000Z");
|
|
const expectedContext = {
|
|
run: { id: "fixture-run", attempt: 1 },
|
|
source: {
|
|
revision: assessment.source.revision,
|
|
sourceSetSha256: assessment.source.sourceSetSha256,
|
|
},
|
|
candidate: {
|
|
archiveSha256,
|
|
bundleSha256: candidate.bundleSha256,
|
|
distSha256: candidate.distSha256,
|
|
lockfileSha256: candidate.lockfileSha256,
|
|
},
|
|
vulnerabilityInvocationNonce: "1".repeat(64),
|
|
provenanceInvocationNonce: "0".repeat(64),
|
|
secretScanAttestation: {
|
|
status: "PASS" as const,
|
|
localEvidenceAssessmentSha256: sha256(
|
|
await readFile(path.join(root, LOCAL_EVIDENCE_ASSESSMENT_PATH)),
|
|
),
|
|
sourceSetSha256: assessment.source.sourceSetSha256,
|
|
policySha256: assessment.secretScan.policySha256,
|
|
sarifSha256: assessment.secretScan.sarifSha256,
|
|
scanInputSha256: assessment.secretScan.scanInputSha256,
|
|
},
|
|
} as const;
|
|
const unsigned = {
|
|
schemaVersion: 2 as const,
|
|
evidenceType: "vulnerability-report" as const,
|
|
provider: "fixture",
|
|
issuedAt: "2026-08-02T01:00:00.000Z",
|
|
expiresAt: "2026-08-02T02:00:00.000Z",
|
|
run: { ...expectedContext.run, invocationNonce: expectedContext.vulnerabilityInvocationNonce },
|
|
source: expectedContext.source,
|
|
candidate: expectedContext.candidate,
|
|
secretScanAttestation: expectedContext.secretScanAttestation,
|
|
findings: [],
|
|
};
|
|
const report = {
|
|
...unsigned,
|
|
signature: {
|
|
algorithm: "Ed25519" as const,
|
|
keyId,
|
|
publicKeyFingerprint: providerPublicKeyFingerprint(keys.publicKey),
|
|
value: sign(
|
|
null,
|
|
providerEvidenceSignaturePayload(unsigned),
|
|
keys.privateKey,
|
|
).toString("base64"),
|
|
},
|
|
};
|
|
const reportPath = path.join(
|
|
root,
|
|
"provider-evidence/untrusted/vulnerability-report.json",
|
|
);
|
|
await writeArtifact(
|
|
root,
|
|
"provider-evidence/untrusted/vulnerability-report.json",
|
|
`${JSON.stringify(report)}\n`,
|
|
);
|
|
const reportBytes = Buffer.from(`${JSON.stringify(report)}\n`);
|
|
return {
|
|
root,
|
|
candidateRoot,
|
|
candidate,
|
|
protectedCandidatePath,
|
|
protectedCandidateBytes,
|
|
archivePath,
|
|
archiveSha256,
|
|
reportPath,
|
|
reportBytes,
|
|
expectedContext,
|
|
trust,
|
|
now,
|
|
publicKeyPath,
|
|
privateKeyPath,
|
|
privateKey: keys.privateKey,
|
|
keyId,
|
|
providerWriter: path.join(root, "provider-v2-writer.mjs"),
|
|
};
|
|
}
|
|
|
|
async function ensureProviderBaseFixture(): Promise<string> {
|
|
if (providerBaseRoot) return providerBaseRoot;
|
|
const sourceRoot = process.cwd();
|
|
const root = await mkdtemp(path.join(tmpdir(), "ci-provider-v2-base-"));
|
|
await cp(sourceRoot, root, {
|
|
recursive: true,
|
|
filter: (source) => {
|
|
const relative = path.relative(sourceRoot, source);
|
|
if (!relative) return true;
|
|
const first = relative.split(path.sep)[0];
|
|
return ![".release", "artifacts", "dist", "node_modules"].includes(first ?? "");
|
|
},
|
|
});
|
|
await cp(path.join(sourceRoot, "artifacts"), path.join(root, "artifacts"), {
|
|
recursive: true,
|
|
});
|
|
await rm(path.join(root, "artifacts/release"), { recursive: true, force: true });
|
|
await symlink(path.join(sourceRoot, "node_modules"), path.join(root, "node_modules"), "dir");
|
|
const git = spawnSync("git", ["show", "-s", "--format=%H%n%ct", "HEAD"], {
|
|
cwd: sourceRoot,
|
|
encoding: "utf8",
|
|
});
|
|
if (git.status !== 0) throw new Error(git.stderr);
|
|
const [revision, sourceDateEpoch] = git.stdout.trim().split(/\r?\n/u);
|
|
const build = spawnSync("corepack", ["pnpm", "build:release-candidate"], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
timeout: 120_000,
|
|
maxBuffer: 32 * 1024 * 1024,
|
|
env: {
|
|
...process.env,
|
|
CI: "true",
|
|
VITE_BUILD_ID: "provider-v2-fixture",
|
|
VITE_COMMIT_SHA: revision,
|
|
RELEASE_ID: "provider-v2-fixture",
|
|
SOURCE_DATE_EPOCH: sourceDateEpoch,
|
|
CI_RUNNER_IMAGE: `fixture@sha256:${"a".repeat(64)}`,
|
|
},
|
|
});
|
|
if (build.status !== 0) throw new Error(`${build.stdout}\n${build.stderr}`);
|
|
const archivePath = path.join(root, "candidate.tar.gz");
|
|
const tar = spawnSync(
|
|
"/usr/bin/tar",
|
|
[
|
|
"--sort=name",
|
|
"--mtime=@0",
|
|
"--owner=0",
|
|
"--group=0",
|
|
"--numeric-owner",
|
|
"-czf",
|
|
archivePath,
|
|
"dist",
|
|
...RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
|
RELEASE_CANDIDATE_MANIFEST_PATH,
|
|
],
|
|
{ cwd: root, encoding: "utf8" },
|
|
);
|
|
if (tar.status !== 0) throw new Error(tar.stderr);
|
|
providerBaseRoot = root;
|
|
return root;
|
|
}
|
|
|
|
async function createPromotionStagingFixture(
|
|
options: Readonly<{ nowEpochMs?: number }> = {},
|
|
) {
|
|
const base = await ensureProviderBaseFixture();
|
|
const root = await temporaryRoot("promotion-finalizer-");
|
|
const archivePath = path.join(root, "inputs/release-candidate.tar.gz");
|
|
await mkdir(path.dirname(archivePath), { recursive: true });
|
|
await cp(path.join(base, "candidate.tar.gz"), archivePath);
|
|
const candidate = await verifyCiCandidateArchive({ archivePath });
|
|
const candidateArchiveBytes = await readFile(archivePath);
|
|
const assessmentBytes = await readFile(path.join(base, LOCAL_EVIDENCE_ASSESSMENT_PATH));
|
|
const assessment = localEvidenceAssessmentArtifactSchema.parse(
|
|
JSON.parse(assessmentBytes.toString("utf8")) as unknown,
|
|
);
|
|
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
|
const provenanceKeys = generateKeyPairSync("ed25519");
|
|
const vulnerabilityKeyId = "fixture-vulnerability";
|
|
const provenanceKeyId = "fixture-provenance";
|
|
const vulnerabilityInvocationNonce = "5".repeat(64);
|
|
const provenanceInvocationNonce = "6".repeat(64);
|
|
const fixtureNow = options.nowEpochMs ?? Date.parse("2026-08-02T01:00:00.000Z");
|
|
const issuedAt = new Date(fixtureNow).toISOString();
|
|
const expiresAt = new Date(fixtureNow + 60 * 60 * 1_000).toISOString();
|
|
const expectedContext = {
|
|
run: { id: "fixture-run", attempt: 1 },
|
|
source: {
|
|
revision: assessment.source.revision,
|
|
sourceSetSha256: assessment.source.sourceSetSha256,
|
|
},
|
|
candidate: {
|
|
archiveSha256: sha256(candidateArchiveBytes),
|
|
bundleSha256: candidate.manifest.bundleSha256,
|
|
distSha256: candidate.manifest.distSha256,
|
|
lockfileSha256: candidate.manifest.lockfileSha256,
|
|
},
|
|
secretScanAttestation: {
|
|
status: "PASS" as const,
|
|
localEvidenceAssessmentSha256: sha256(assessmentBytes),
|
|
sourceSetSha256: assessment.source.sourceSetSha256,
|
|
policySha256: assessment.secretScan.policySha256,
|
|
sarifSha256: assessment.secretScan.sarifSha256,
|
|
scanInputSha256: assessment.secretScan.scanInputSha256,
|
|
},
|
|
} as const;
|
|
const vulnerabilityUnsigned = {
|
|
schemaVersion: 2 as const,
|
|
evidenceType: "vulnerability-report" as const,
|
|
provider: "fixture-vulnerability",
|
|
issuedAt,
|
|
expiresAt,
|
|
run: { ...expectedContext.run, invocationNonce: vulnerabilityInvocationNonce },
|
|
source: expectedContext.source,
|
|
candidate: expectedContext.candidate,
|
|
secretScanAttestation: expectedContext.secretScanAttestation,
|
|
findings: [],
|
|
};
|
|
const vulnerabilityReport = {
|
|
...vulnerabilityUnsigned,
|
|
signature: {
|
|
algorithm: "Ed25519" as const,
|
|
keyId: vulnerabilityKeyId,
|
|
publicKeyFingerprint: providerPublicKeyFingerprint(vulnerabilityKeys.publicKey),
|
|
value: sign(
|
|
null,
|
|
providerEvidenceSignaturePayload(vulnerabilityUnsigned),
|
|
vulnerabilityKeys.privateKey,
|
|
).toString("base64"),
|
|
},
|
|
};
|
|
const provenanceUnsigned = {
|
|
schemaVersion: 2 as const,
|
|
evidenceType: "provenance-attestation" as const,
|
|
provider: "fixture-provenance",
|
|
signer: "fixture-signer",
|
|
issuedAt,
|
|
expiresAt,
|
|
run: { ...expectedContext.run, invocationNonce: provenanceInvocationNonce },
|
|
source: expectedContext.source,
|
|
candidate: expectedContext.candidate,
|
|
subject: {
|
|
name: "dist" as const,
|
|
digest: { sha256: candidate.manifest.distSha256 },
|
|
},
|
|
};
|
|
const provenanceAttestation = {
|
|
...provenanceUnsigned,
|
|
signature: {
|
|
algorithm: "Ed25519" as const,
|
|
keyId: provenanceKeyId,
|
|
publicKeyFingerprint: providerPublicKeyFingerprint(provenanceKeys.publicKey),
|
|
value: sign(
|
|
null,
|
|
providerEvidenceSignaturePayload(provenanceUnsigned),
|
|
provenanceKeys.privateKey,
|
|
).toString("base64"),
|
|
},
|
|
};
|
|
const vulnerabilityReportBytes = Buffer.from(`${JSON.stringify(vulnerabilityReport)}\n`);
|
|
const provenanceAttestationBytes = Buffer.from(`${JSON.stringify(provenanceAttestation)}\n`);
|
|
const vulnerabilityReportPath = path.join(
|
|
root,
|
|
"inputs/vulnerability-report.json",
|
|
);
|
|
const provenanceAttestationPath = path.join(
|
|
root,
|
|
"inputs/provenance-attestation.json",
|
|
);
|
|
const vulnerabilityPublicKeyPath = path.join(root, "keys/vulnerability.pem");
|
|
const provenancePublicKeyPath = path.join(root, "keys/provenance.pem");
|
|
await writeArtifact(
|
|
root,
|
|
"inputs/vulnerability-report.json",
|
|
vulnerabilityReportBytes,
|
|
);
|
|
await writeArtifact(
|
|
root,
|
|
"inputs/provenance-attestation.json",
|
|
provenanceAttestationBytes,
|
|
);
|
|
await writeArtifact(
|
|
root,
|
|
"keys/vulnerability.pem",
|
|
vulnerabilityKeys.publicKey.export({ type: "spki", format: "pem" }),
|
|
);
|
|
await writeArtifact(
|
|
root,
|
|
"keys/provenance.pem",
|
|
provenanceKeys.publicKey.export({ type: "spki", format: "pem" }),
|
|
);
|
|
const runnerTempRoot = path.join(root, "runner-temp");
|
|
await mkdir(runnerTempRoot, { mode: 0o700 });
|
|
const cleanupToken = `promotion-fixture-run-1-${"2a".repeat(16)}`;
|
|
const expectedProviderEvidence = {
|
|
vulnerabilityReportSha256: sha256(vulnerabilityReportBytes),
|
|
provenanceAttestationSha256: sha256(provenanceAttestationBytes),
|
|
vulnerabilityInvocationNonce,
|
|
provenanceInvocationNonce,
|
|
vulnerabilityKeyId,
|
|
vulnerabilityKeyFingerprint: providerPublicKeyFingerprint(vulnerabilityKeys.publicKey),
|
|
provenanceKeyId,
|
|
provenanceKeyFingerprint: providerPublicKeyFingerprint(provenanceKeys.publicKey),
|
|
secretScanAttestation: expectedContext.secretScanAttestation,
|
|
};
|
|
return {
|
|
root,
|
|
runnerTempRoot,
|
|
cleanupToken,
|
|
assessmentSha256: sha256(assessmentBytes),
|
|
expectedContext,
|
|
expectedProviderEvidence,
|
|
vulnerabilityPrivateKey: vulnerabilityKeys.privateKey,
|
|
bundleVerification: {
|
|
vulnerabilityTrust: {
|
|
keyId: vulnerabilityKeyId,
|
|
publicKey: vulnerabilityKeys.publicKey,
|
|
publicKeyFingerprint: providerPublicKeyFingerprint(vulnerabilityKeys.publicKey),
|
|
},
|
|
provenanceTrust: {
|
|
keyId: provenanceKeyId,
|
|
publicKey: provenanceKeys.publicKey,
|
|
publicKeyFingerprint: providerPublicKeyFingerprint(provenanceKeys.publicKey),
|
|
},
|
|
expected: {
|
|
run: expectedContext.run,
|
|
sourceRevision: expectedContext.source.revision,
|
|
sourceSetSha256: expectedContext.source.sourceSetSha256,
|
|
archiveSha256: expectedContext.candidate.archiveSha256,
|
|
bundleSha256: expectedContext.candidate.bundleSha256,
|
|
distSha256: expectedContext.candidate.distSha256,
|
|
lockfileSha256: expectedContext.candidate.lockfileSha256,
|
|
},
|
|
nowEpochMs: () => fixtureNow,
|
|
},
|
|
capturedSources: new Map([
|
|
["release-candidate.tar.gz", candidateArchiveBytes],
|
|
["vulnerability-report.json", vulnerabilityReportBytes],
|
|
["provenance-attestation.json", provenanceAttestationBytes],
|
|
]),
|
|
dependencies: {
|
|
nowEpochMs: () => fixtureNow,
|
|
randomBytes: (bytes: number) => Buffer.alloc(bytes, 0x2a),
|
|
},
|
|
input: {
|
|
repositoryRoot: root,
|
|
archivePath,
|
|
expectedArchiveSha256: sha256(candidateArchiveBytes),
|
|
vulnerabilityReportPath,
|
|
provenanceAttestationPath,
|
|
vulnerabilityPublicKeyPath,
|
|
vulnerabilityKeyId,
|
|
provenancePublicKeyPath,
|
|
provenanceKeyId,
|
|
expectedRun: {
|
|
id: expectedContext.run.id,
|
|
attempt: expectedContext.run.attempt,
|
|
sourceRevision: expectedContext.source.revision,
|
|
},
|
|
vulnerabilityInvocationNonce,
|
|
provenanceInvocationNonce,
|
|
runnerTempRoot,
|
|
},
|
|
};
|
|
}
|
|
|
|
function finalizedCleanup(
|
|
fixture: Awaited<ReturnType<typeof createPromotionStagingFixture>>,
|
|
finalized: Awaited<ReturnType<typeof stageVerifiedPromotion>>,
|
|
) {
|
|
return {
|
|
runnerTempRoot: fixture.runnerTempRoot,
|
|
stagingRoot: finalized.stagingRoot,
|
|
cleanupToken: finalized.cleanupToken,
|
|
runnerTempIdentity: finalized.runnerTempIdentity,
|
|
stagingIdentity: finalized.stagingIdentity,
|
|
};
|
|
}
|