1056 lines
44 KiB
TypeScript
1056 lines
44 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { createHash, generateKeyPairSync, sign } from "node:crypto";
|
|
import { link, mkdir, mkdtemp, open, readFile, readdir, rename, rm, 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 { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import type {
|
|
CiGateArtifact,
|
|
CiGateArtifactSchema,
|
|
} from "../../scripts/contracts/ci-gates.ts";
|
|
import { readBoundedRegularFile, validateCiArtifact } from "../../scripts/lib/ci-artifact-validator.ts";
|
|
import { writeCiGateLogAtomic } from "../../scripts/lib/ci-gate-log.ts";
|
|
import { 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 {
|
|
PROMOTED_STAGING_PATHS,
|
|
stageVerifiedPromotion,
|
|
} from "../../scripts/lib/promotion-stager.ts";
|
|
import {
|
|
providerEvidenceSignaturePayload,
|
|
providerVerificationArtifactSchema,
|
|
} from "../../scripts/lib/provider-evidence.ts";
|
|
import {
|
|
createReleaseCandidateManifest,
|
|
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
|
RELEASE_CANDIDATE_MANIFEST_PATH,
|
|
} from "../../scripts/lib/release-candidate.ts";
|
|
|
|
const temporaryRoots: string[] = [];
|
|
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 })),
|
|
);
|
|
});
|
|
|
|
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"); }, /record|object/i],
|
|
["array JSON", async (root: string): Promise<void> => { await writeArtifact(root, "report.json", "[]\n"); }, /record|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", "generic"),
|
|
schema: {
|
|
id: "generic",
|
|
kind: "json",
|
|
maxBytes: _name === "oversized" ? 4 : 4_096,
|
|
executableSchemaId: "generic-json-object",
|
|
},
|
|
}),
|
|
).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: "generic",
|
|
kind: "json",
|
|
maxBytes: 4_096,
|
|
executableSchemaId: "generic-json-object",
|
|
} as const;
|
|
await expect(
|
|
validateCiArtifact({ root, artifact: artifact("leaf.json", "generic"), schema }),
|
|
).rejects.toThrow(/not a regular file/i);
|
|
await expect(
|
|
validateCiArtifact({
|
|
root,
|
|
artifact: artifact("linked-directory/report.json", "generic"),
|
|
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/>"],
|
|
["broken.html", { id: "html", kind: "html", maxBytes: 1_024 }, "<!doctype html><html>"],
|
|
] 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("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({
|
|
kind: "vulnerability",
|
|
candidateRoot: fixture.candidateRoot,
|
|
archivePath: fixture.archivePath,
|
|
expectedArchiveSha256: fixture.archiveSha256,
|
|
workspaceRoot: fixture.root,
|
|
reportPath: fixture.reportPath,
|
|
expectedDistSha256: fixture.distSha256,
|
|
}),
|
|
).resolves.toEqual(expect.objectContaining({ provider: "fixture" }));
|
|
const report = JSON.parse(await readFile(fixture.reportPath, "utf8")) as Record<string, unknown>;
|
|
report.scannedDistSha256 = "f".repeat(64);
|
|
await writeFile(fixture.reportPath, `${JSON.stringify(report)}\n`);
|
|
await expect(
|
|
validateProviderUpload({
|
|
kind: "vulnerability",
|
|
candidateRoot: fixture.candidateRoot,
|
|
archivePath: fixture.archivePath,
|
|
expectedArchiveSha256: fixture.archiveSha256,
|
|
workspaceRoot: fixture.root,
|
|
reportPath: fixture.reportPath,
|
|
expectedDistSha256: fixture.distSha256,
|
|
}),
|
|
).rejects.toThrow(/candidate digest mismatch/i);
|
|
});
|
|
|
|
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({
|
|
kind: "vulnerability",
|
|
candidateRoot: fixture.candidateRoot,
|
|
archivePath: fixture.archivePath,
|
|
expectedArchiveSha256: fixture.archiveSha256,
|
|
workspaceRoot: fixture.root,
|
|
reportPath: fixture.reportPath,
|
|
expectedDistSha256: fixture.distSha256,
|
|
}),
|
|
).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(
|
|
validateProviderUpload({
|
|
kind: "vulnerability",
|
|
candidateRoot: fixture.candidateRoot,
|
|
archivePath: fixture.archivePath,
|
|
expectedArchiveSha256: fixture.archiveSha256,
|
|
workspaceRoot: fixture.root,
|
|
reportPath: fixture.reportPath,
|
|
expectedDistSha256: fixture.distSha256,
|
|
}),
|
|
).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(
|
|
validateProviderUpload({
|
|
kind: "vulnerability",
|
|
candidateRoot: fixture.candidateRoot,
|
|
archivePath: fixture.archivePath,
|
|
expectedArchiveSha256: fixture.archiveSha256,
|
|
workspaceRoot: fixture.root,
|
|
reportPath: fixture.reportPath,
|
|
expectedDistSha256: fixture.distSha256,
|
|
}),
|
|
).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("drains and kills provider background processes before sealing evidence", async () => {
|
|
const fixture = await createProviderFixture();
|
|
const rawReport = await readFile(fixture.reportPath, "utf8");
|
|
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(path.join(fixture.candidateRoot, "dist/app.js"))}, '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';",
|
|
"import { writeFileSync } from 'node:fs';",
|
|
`writeFileSync(${JSON.stringify(fixture.reportPath)}, ${JSON.stringify(rawReport)});`,
|
|
`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(path.join(fixture.candidateRoot, "dist/app.js"), "utf8")).resolves.toBe("app\n");
|
|
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();
|
|
const rawReport = await readFile(fixture.reportPath, "utf8");
|
|
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 {}`,
|
|
`writeFileSync(${JSON.stringify(fixture.reportPath)}, ${JSON.stringify(rawReport)});`,
|
|
].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);
|
|
});
|
|
|
|
describe("verified promotion staging", () => {
|
|
it("publishes the exact five captured promotion inputs", async () => {
|
|
const fixture = await createPromotionStagingFixture();
|
|
const staged = await stageVerifiedPromotion(fixture.input, fixture.dependencies);
|
|
expect(staged.map(({ path: stagedPath }) => stagedPath)).toEqual(PROMOTED_STAGING_PATHS);
|
|
for (const [stagedPath, expected] of fixture.expectedStagedBytes) {
|
|
await expect(readFile(path.join(fixture.root, stagedPath))).resolves.toEqual(expected);
|
|
expect(staged.find(({ path: candidate }) => candidate === stagedPath)?.sha256).toBe(
|
|
sha256(expected),
|
|
);
|
|
}
|
|
expect(staged).toHaveLength(5);
|
|
await writeFile(fixture.input.vulnerabilityReportPath, "mutated after capture\n");
|
|
await expect(
|
|
readFile(path.join(fixture.root, ".release/promoted-staging/vulnerability-report.json")),
|
|
).resolves.toEqual(
|
|
fixture.expectedStagedBytes.get(
|
|
".release/promoted-staging/vulnerability-report.json",
|
|
),
|
|
);
|
|
});
|
|
|
|
it("rejects archive digest drift before publishing staging", async () => {
|
|
const fixture = await createPromotionStagingFixture();
|
|
await expect(
|
|
stageVerifiedPromotion({
|
|
...fixture.input,
|
|
expectedArchiveSha256: "0".repeat(64),
|
|
}, fixture.dependencies),
|
|
).rejects.toThrow(/archive SHA-256/u);
|
|
await expect(
|
|
readFile(path.join(fixture.root, ".release/promoted-staging/release-candidate.tar.gz")),
|
|
).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it("rejects a byte-different signed report whose producer digest is stale", async () => {
|
|
const fixture = await createPromotionStagingFixture();
|
|
const report = JSON.parse(
|
|
await readFile(fixture.input.vulnerabilityReportPath, "utf8"),
|
|
) as unknown;
|
|
const replacement = Buffer.from(`${JSON.stringify(report, null, 2)}\n`);
|
|
await writeFile(fixture.input.vulnerabilityReportPath, replacement);
|
|
await expect(stageVerifiedPromotion(fixture.input, fixture.dependencies)).rejects.toThrow(
|
|
/vulnerabilityReportSha256 digest mismatch/i,
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
["provider-verification.json", "promotion-verification.json"],
|
|
["promotion-verification.json", "provider-verification.json"],
|
|
] as const)("rejects %s copied into the %s role", async (sourceName, targetName) => {
|
|
const fixture = await createPromotionStagingFixture();
|
|
const sourcePath = path.join(fixture.root, "artifacts/security", sourceName);
|
|
const targetPath = path.join(fixture.root, "artifacts/security", targetName);
|
|
await writeFile(targetPath, await readFile(sourcePath));
|
|
await expect(stageVerifiedPromotion(fixture.input, fixture.dependencies)).rejects.toThrow(
|
|
/artifactType role mismatch/i,
|
|
);
|
|
});
|
|
|
|
it("stages captured bytes and uses captured trust keys after source mutation", async () => {
|
|
const fixture = await createPromotionStagingFixture();
|
|
const sourceMappings = [
|
|
[fixture.input.archivePath, ".release/promoted-staging/release-candidate.tar.gz"],
|
|
[fixture.input.vulnerabilityReportPath, ".release/promoted-staging/vulnerability-report.json"],
|
|
[fixture.input.provenanceAttestationPath, ".release/promoted-staging/provenance-attestation.json"],
|
|
[path.join(fixture.root, "artifacts/security/provider-verification.json"), ".release/promoted-staging/provider-verification.json"],
|
|
[path.join(fixture.root, "artifacts/security/promotion-verification.json"), ".release/promoted-staging/promotion-verification.json"],
|
|
] as const;
|
|
const originalSources = new Map<string, Buffer>(await Promise.all(
|
|
sourceMappings.map(async ([sourcePath]) => [sourcePath, await readFile(sourcePath)] as const),
|
|
));
|
|
const staged = 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(path.join(fixture.root, "artifacts/security/provider-verification.json"), "replaced provider verification\n"),
|
|
writeFile(path.join(fixture.root, "artifacts/security/promotion-verification.json"), "replaced promotion verification\n"),
|
|
writeFile(fixture.input.vulnerabilityPublicKeyPath, "replaced key\n"),
|
|
writeFile(fixture.input.provenancePublicKeyPath, "replaced key\n"),
|
|
]);
|
|
},
|
|
});
|
|
for (const [sourcePath, stagedPath] of sourceMappings) {
|
|
const original = originalSources.get(sourcePath)!;
|
|
await expect(readFile(path.join(fixture.root, stagedPath))).resolves.toEqual(original);
|
|
expect(staged.find(({ path: candidate }) => candidate === stagedPath)?.sha256)
|
|
.toBe(sha256(original));
|
|
}
|
|
});
|
|
|
|
it("reruns archived local evidence instead of trusting a pre-existing PASS JSON", async () => {
|
|
const fixture = await createPromotionStagingFixture();
|
|
await expect(stageVerifiedPromotion(fixture.input)).rejects.toThrow(
|
|
/captured local evidence failed final verification/i,
|
|
);
|
|
});
|
|
|
|
it("rejects symlinked and oversized promotion sources", 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);
|
|
|
|
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);
|
|
});
|
|
|
|
it.each(["directory", "symlink"] as const)(
|
|
"rejects a pre-existing %s staging target",
|
|
async (targetKind) => {
|
|
const fixture = await createPromotionStagingFixture();
|
|
const target = path.join(fixture.root, ".release/promoted-staging");
|
|
if (targetKind === "directory") {
|
|
await mkdir(target, { recursive: true });
|
|
} else {
|
|
const outside = await temporaryRoot("promotion-staging-outside-");
|
|
await symlink(outside, target);
|
|
}
|
|
await expect(stageVerifiedPromotion(fixture.input, fixture.dependencies)).rejects.toThrow(
|
|
/publish leaf is unsafe|target already exists/i,
|
|
);
|
|
},
|
|
);
|
|
|
|
it("rejects a .release ancestor symlink introduced after capture", async () => {
|
|
const fixture = await createPromotionStagingFixture();
|
|
const original = path.join(fixture.root, ".release-original");
|
|
const outside = await temporaryRoot("promotion-release-symlink-");
|
|
await expect(
|
|
stageVerifiedPromotion(fixture.input, {
|
|
...fixture.dependencies,
|
|
afterCapture: async () => {
|
|
await rename(path.join(fixture.root, ".release"), original);
|
|
await symlink(outside, path.join(fixture.root, ".release"));
|
|
},
|
|
}),
|
|
).rejects.toThrow(/publish ancestor is unsafe|publish directory/u);
|
|
await expect(readdir(outside)).resolves.toEqual([]);
|
|
});
|
|
|
|
it("detects a staging parent identity swap and cleans its owned temporary", async () => {
|
|
const fixture = await createPromotionStagingFixture();
|
|
const displaced = path.join(fixture.root, ".release-displaced");
|
|
const dependencies = {
|
|
...fixture.dependencies,
|
|
beforePublishRename: async () => {
|
|
await rename(path.join(fixture.root, ".release"), displaced);
|
|
await mkdir(path.join(fixture.root, ".release"));
|
|
},
|
|
} as Parameters<typeof stageVerifiedPromotion>[1];
|
|
await expect(stageVerifiedPromotion(fixture.input, dependencies)).rejects.toThrow(
|
|
/parent identity changed/u,
|
|
);
|
|
expect((await readdir(displaced)).filter((entry) => entry.startsWith(".promoted-staging."))).toEqual([]);
|
|
});
|
|
});
|
|
|
|
function runProviderSupervisor(
|
|
fixture: Awaited<ReturnType<typeof createProviderFixture>>,
|
|
input: Readonly<{ command: string; sealedPath: string }>,
|
|
) {
|
|
return spawnSync(
|
|
process.execPath,
|
|
[path.resolve("scripts/run-and-validate-provider.ts"), "--kind", "vulnerability"],
|
|
{
|
|
cwd: fixture.root,
|
|
encoding: "utf8",
|
|
timeout: 8_000,
|
|
env: {
|
|
...process.env,
|
|
VULNERABILITY_PROVIDER_COMMAND: input.command,
|
|
VULNERABILITY_REPORT_PATH: fixture.reportPath,
|
|
VALIDATED_PROVIDER_REPORT_PATH: input.sealedPath,
|
|
CANDIDATE_LOCKFILE_PATH: path.join(fixture.candidateRoot, "pnpm-lock.yaml"),
|
|
CANDIDATE_ARCHIVE_PATH: fixture.archivePath,
|
|
CANDIDATE_ARCHIVE_SHA256: fixture.archiveSha256,
|
|
CANDIDATE_DIST_SHA256: fixture.distSha256,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
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 root = await temporaryRoot("ci-provider-upload-");
|
|
const candidateRoot = path.join(root, "candidate");
|
|
const lockfile = Buffer.from("lockfileVersion: '9.0'\n");
|
|
const lockfileSha256 = sha256(lockfile);
|
|
await writeArtifact(candidateRoot, "pnpm-lock.yaml", lockfile);
|
|
await writeArtifact(candidateRoot, "dist/app.js", "app\n");
|
|
for (const evidencePath of RELEASE_CANDIDATE_EVIDENCE_PATHS) {
|
|
if (evidencePath === "pnpm-lock.yaml") continue;
|
|
const content = evidencePath === "artifacts/release/dependency-inventory.json"
|
|
? `${JSON.stringify({ lockfileSha256 })}\n`
|
|
: `${evidencePath}\n`;
|
|
await writeArtifact(candidateRoot, evidencePath, content);
|
|
}
|
|
const candidate = await createReleaseCandidateManifest(candidateRoot);
|
|
await writeArtifact(candidateRoot, RELEASE_CANDIDATE_MANIFEST_PATH, `${JSON.stringify(candidate)}\n`);
|
|
const distSha256 = candidate.distSha256;
|
|
const archivePath = path.join(root, "candidate.tar.gz");
|
|
const tar = spawnSync(
|
|
"tar",
|
|
["-czf", archivePath, "dist", ...RELEASE_CANDIDATE_EVIDENCE_PATHS, RELEASE_CANDIDATE_MANIFEST_PATH],
|
|
{ cwd: candidateRoot, encoding: "utf8" },
|
|
);
|
|
if (tar.status !== 0) throw new Error(tar.stderr);
|
|
const archiveSha256 = sha256(
|
|
await import("node:fs/promises").then(({ readFile }) => readFile(archivePath)),
|
|
);
|
|
const reportPath = path.join(
|
|
root,
|
|
"provider-evidence/untrusted/vulnerability-report.json",
|
|
);
|
|
await writeArtifact(
|
|
root,
|
|
"provider-evidence/untrusted/vulnerability-report.json",
|
|
`${JSON.stringify({ schemaVersion: 1, provider: "fixture", generatedAt: "2026-08-02T00:00:00.000Z", scannedLockfileSha256: lockfileSha256, scannedDistSha256: distSha256, findings: [], signature: { algorithm: "Ed25519", keyId: "fixture", value: "AA==" } })}\n`,
|
|
);
|
|
return { root, candidateRoot, archivePath, archiveSha256, reportPath, distSha256 };
|
|
}
|
|
|
|
async function createPromotionStagingFixture() {
|
|
const candidateFixture = await createCandidateArchiveFixture();
|
|
const root = path.dirname(candidateFixture.archivePath);
|
|
const candidate = await verifyCiCandidateArchive({
|
|
archivePath: candidateFixture.archivePath,
|
|
});
|
|
const vulnerabilityKeys = generateKeyPairSync("ed25519");
|
|
const provenanceKeys = generateKeyPairSync("ed25519");
|
|
const vulnerabilityKeyId = "fixture-vulnerability";
|
|
const provenanceKeyId = "fixture-provenance";
|
|
const vulnerabilityUnsigned = {
|
|
schemaVersion: 1 as const,
|
|
provider: "fixture-vulnerability",
|
|
generatedAt: "2026-08-02T00:00:00.000Z",
|
|
scannedLockfileSha256: candidate.manifest.lockfileSha256,
|
|
scannedDistSha256: candidate.manifest.distSha256,
|
|
findings: [],
|
|
};
|
|
const vulnerabilityReport = {
|
|
...vulnerabilityUnsigned,
|
|
signature: {
|
|
algorithm: "Ed25519" as const,
|
|
keyId: vulnerabilityKeyId,
|
|
value: sign(
|
|
null,
|
|
providerEvidenceSignaturePayload(vulnerabilityUnsigned),
|
|
vulnerabilityKeys.privateKey,
|
|
).toString("base64"),
|
|
},
|
|
};
|
|
const provenanceUnsigned = {
|
|
schemaVersion: 1 as const,
|
|
provider: "fixture-provenance",
|
|
signer: "fixture-signer",
|
|
generatedAt: "2026-08-02T00:00:00.000Z",
|
|
subject: {
|
|
name: "dist" as const,
|
|
digest: { sha256: candidate.manifest.distSha256 },
|
|
},
|
|
};
|
|
const provenanceAttestation = {
|
|
...provenanceUnsigned,
|
|
signature: {
|
|
algorithm: "Ed25519" as const,
|
|
keyId: provenanceKeyId,
|
|
value: sign(
|
|
null,
|
|
providerEvidenceSignaturePayload(provenanceUnsigned),
|
|
provenanceKeys.privateKey,
|
|
).toString("base64"),
|
|
},
|
|
};
|
|
const candidateArchiveBytes = await readFile(candidateFixture.archivePath);
|
|
const vulnerabilityReportBytes = Buffer.from(`${JSON.stringify(vulnerabilityReport)}\n`);
|
|
const provenanceAttestationBytes = Buffer.from(`${JSON.stringify(provenanceAttestation)}\n`);
|
|
const verificationBindings = {
|
|
candidateArchiveSha256: sha256(candidateArchiveBytes),
|
|
vulnerabilityReportSha256: sha256(vulnerabilityReportBytes),
|
|
provenanceAttestationSha256: sha256(provenanceAttestationBytes),
|
|
};
|
|
const verificationBase = {
|
|
schemaVersion: 2 as const,
|
|
status: "PASS" as const,
|
|
vulnerabilityStatus: "PASS" as const,
|
|
provenanceAttestationStatus: "PASS" as const,
|
|
lockfileSha256: candidate.manifest.lockfileSha256,
|
|
distSha256: candidate.manifest.distSha256,
|
|
...verificationBindings,
|
|
failures: [],
|
|
};
|
|
const providerVerificationBytes = Buffer.from(`${JSON.stringify({
|
|
...verificationBase,
|
|
artifactType: "provider-verification",
|
|
})}\n`);
|
|
const promotionVerificationBytes = Buffer.from(`${JSON.stringify({
|
|
...verificationBase,
|
|
artifactType: "promotion-verification",
|
|
})}\n`);
|
|
const vulnerabilityReportPath = path.join(
|
|
root,
|
|
".release/vulnerability/vulnerability-report.json",
|
|
);
|
|
const provenanceAttestationPath = path.join(
|
|
root,
|
|
".release/provenance/provenance-attestation.json",
|
|
);
|
|
const vulnerabilityPublicKeyPath = path.join(root, "keys/vulnerability.pem");
|
|
const provenancePublicKeyPath = path.join(root, "keys/provenance.pem");
|
|
await writeArtifact(
|
|
root,
|
|
".release/vulnerability/vulnerability-report.json",
|
|
vulnerabilityReportBytes,
|
|
);
|
|
await writeArtifact(
|
|
root,
|
|
".release/provenance/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" }),
|
|
);
|
|
await writeArtifact(
|
|
root,
|
|
"artifacts/security/provider-verification.json",
|
|
providerVerificationBytes,
|
|
);
|
|
await writeArtifact(
|
|
root,
|
|
"artifacts/security/promotion-verification.json",
|
|
promotionVerificationBytes,
|
|
);
|
|
const expectedStagedBytes = new Map<string, Buffer>([
|
|
[
|
|
".release/promoted-staging/release-candidate.tar.gz",
|
|
candidateArchiveBytes,
|
|
],
|
|
[
|
|
".release/promoted-staging/vulnerability-report.json",
|
|
await readFile(vulnerabilityReportPath),
|
|
],
|
|
[
|
|
".release/promoted-staging/provenance-attestation.json",
|
|
await readFile(provenanceAttestationPath),
|
|
],
|
|
[
|
|
".release/promoted-staging/provider-verification.json",
|
|
await readFile(path.join(root, "artifacts/security/provider-verification.json")),
|
|
],
|
|
[
|
|
".release/promoted-staging/promotion-verification.json",
|
|
await readFile(path.join(root, "artifacts/security/promotion-verification.json")),
|
|
],
|
|
]);
|
|
return {
|
|
root,
|
|
dependencies: {
|
|
verifyLocalEvidence: async () => ({ status: "PASS" as const, failures: [] }),
|
|
},
|
|
expectedStagedBytes,
|
|
input: {
|
|
repositoryRoot: root,
|
|
archivePath: candidateFixture.archivePath,
|
|
expectedArchiveSha256: sha256(await readFile(candidateFixture.archivePath)),
|
|
vulnerabilityReportPath,
|
|
provenanceAttestationPath,
|
|
vulnerabilityPublicKeyPath,
|
|
vulnerabilityKeyId,
|
|
provenancePublicKeyPath,
|
|
provenanceKeyId,
|
|
},
|
|
};
|
|
}
|