fix: close release provenance and privacy gaps

This commit is contained in:
2026-09-02 11:25:52 +03:00
parent 7da2b72933
commit 79e5d04395
17 changed files with 2271 additions and 704 deletions

View File

@@ -6,9 +6,11 @@ import {
symlink,
writeFile,
} from "node:fs/promises";
import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { canonicalJsonDigest } from "../../src/security/canonical-digest.ts";
// @ts-expect-error executable release modules intentionally have no browser declarations
import { enumerateClosure } from "../../scripts/release/enumerate-closure.mjs";
@@ -16,10 +18,24 @@ import { enumerateClosure } from "../../scripts/release/enumerate-closure.mjs";
import * as privacyRunner from "../../scripts/release/run-privacy-workers.mjs";
// @ts-expect-error executable release modules intentionally have no browser declarations
import { reconcileScan } from "../../scripts/release/reconcile-scan.mjs";
// @ts-expect-error container worker is executable JavaScript
import { inspectObject } from "../../containers/privacy-worker-a/worker.mjs";
// @ts-expect-error container matcher is executable JavaScript
import { matchReports } from "../../containers/privacy-matcher/matcher.mjs";
const { buildWorkerCommand, validateScanObjectDescriptor } = privacyRunner;
const {
buildWorkerCommand,
validateScanObjectDescriptor,
requireLiveContainerValidation,
} = privacyRunner;
const h = (c: string) => c.repeat(64);
const sourceClosure = (members: unknown[] = []) => {
return { schema: "portfolio-publication-closure/v1", members };
};
const publicClosure = (members: unknown[] = []) => {
return { schema: "portfolio-public-object-closure/v1", members };
};
function report(
workerId: "worker-a" | "worker-b",
@@ -56,6 +72,124 @@ function matcher(override: Record<string, unknown> = {}) {
}
describe("isolated privacy closure and reconciliation", () => {
test("fails closed while live scanners are pending and for every non-empty public object closure", () => {
expect(() =>
requireLiveContainerValidation({
schema: "portfolio-live-container-validation/v1",
status: "pending",
reason: "not verified",
}),
).toThrow(/pending|verified/i);
const canary = Buffer.from("PRIVATE-PROJECT-NAME\nTOKEN=secret-canary");
const inputDigest = createHash("sha256").update(canary).digest("hex");
expect(
inspectObject(
{
schema: "portfolio-privacy-worker-request/v1",
workerId: "worker-a",
imageDigest: h("a"),
inputDigest,
checkId: "secret",
},
canary,
).verdict,
).not.toBe("pass");
const pythonReport = report("worker-b", {
inputDigest,
checkId: "secret",
verdict: "unsupported",
});
expect(() =>
matchReports({
reports: [
inspectObject(
{
schema: "portfolio-privacy-worker-request/v1",
workerId: "worker-a",
imageDigest: h("a"),
inputDigest,
checkId: "secret",
},
canary,
),
pythonReport,
],
rules: [{ id: "private-name-v1", deniedMemberDigests: [] }],
ruleSetDigest: h("4"),
}),
).toThrow(/pass|unsupported|agree/i);
const source = sourceClosure();
const publicObjectClosure = publicClosure([
{
objectId: "private-leak.txt",
inputDigest,
requiredChecks: ["secret"],
},
]);
expect(() =>
reconcileScan({
sourceClosure: source,
publicObjectClosure,
reports: [
report("worker-a", { inputDigest, checkId: "secret" }),
report("worker-b", { inputDigest, checkId: "secret" }),
],
matcherResults: [matcher({ inputDigest, checkId: "secret" })],
candidate: {
schema: "portfolio-candidate-release-manifest/v1",
buildId: "r1",
sourceClosureDigest: canonicalJsonDigest(
"portfolio-publication-closure/v1",
{ schema: source.schema, members: source.members },
),
publicObjectClosureDigest: canonicalJsonDigest(
"portfolio-public-object-closure/v1",
publicObjectClosure,
),
},
}),
).toThrow(/unavailable|non-empty|scanner/i);
});
test("derives exact scan coverage from canonical closures instead of caller expectedChecks", () => {
const source = sourceClosure();
const publicObjectClosure = publicClosure();
const candidate = {
schema: "portfolio-candidate-release-manifest/v1",
buildId: "r1",
sourceClosureDigest: canonicalJsonDigest(
"portfolio-publication-closure/v1",
source,
),
publicObjectClosureDigest: canonicalJsonDigest(
"portfolio-public-object-closure/v1",
publicObjectClosure,
),
};
const result = reconcileScan({
sourceClosure: source,
publicObjectClosure,
reports: [],
matcherResults: [],
candidate,
});
expect(result.evidence).toMatchObject({
coverageCount: 0,
passedCount: 0,
sourceClosureDigest: candidate.sourceClosureDigest,
publicObjectClosureDigest: candidate.publicObjectClosureDigest,
});
expect(() =>
reconcileScan({
sourceClosure: source,
publicObjectClosure,
expectedChecks: [{ inputDigest: h("1"), checkId: "bytes" }],
reports: [],
matcherResults: [],
candidate,
}),
).toThrow(/missing|extra|unknown|caller|expectedChecks/i);
});
test("enumerates an exact byte-sorted closure and rejects unsafe filesystem members", async () => {
const root = await mkdtemp(join(tmpdir(), "portfolio-closure-"));
await mkdir(join(root, "nested"));
@@ -142,14 +276,25 @@ describe("isolated privacy closure and reconciliation", () => {
).toThrowError(expect.not.stringContaining(secret));
});
test("accepts only ordered heterogeneous agreement for every expected check", () => {
test("accepts only the exact empty public object closure while scanners are unavailable", () => {
const source = sourceClosure();
const publicObjectClosure = publicClosure();
const result = reconcileScan({
expectedChecks: [{ inputDigest: h("1"), checkId: "bytes" }],
reports: [report("worker-a"), report("worker-b")],
matcherResults: [matcher()],
sourceClosure: source,
publicObjectClosure,
reports: [],
matcherResults: [],
candidate: {
schema: "portfolio-candidate-release-manifest/v1",
buildId: "r1",
sourceClosureDigest: canonicalJsonDigest(
"portfolio-publication-closure/v1",
source,
),
publicObjectClosureDigest: canonicalJsonDigest(
"portfolio-public-object-closure/v1",
publicObjectClosure,
),
},
});
expect(result.evidenceDigest).toMatch(/^[a-f0-9]{64}$/);
@@ -217,13 +362,24 @@ describe("isolated privacy closure and reconciliation", () => {
});
test("writes canonical evidence and candidate objects content-addressed without overwrite", async () => {
const source = sourceClosure();
const publicObjectClosure = publicClosure();
const result = reconcileScan({
expectedChecks: [{ inputDigest: h("1"), checkId: "bytes" }],
reports: [report("worker-a"), report("worker-b")],
matcherResults: [matcher()],
sourceClosure: source,
publicObjectClosure,
reports: [],
matcherResults: [],
candidate: {
schema: "portfolio-candidate-release-manifest/v1",
buildId: "r1",
sourceClosureDigest: canonicalJsonDigest(
"portfolio-publication-closure/v1",
source,
),
publicObjectClosureDigest: canonicalJsonDigest(
"portfolio-public-object-closure/v1",
publicObjectClosure,
),
},
});
const root = await mkdtemp(join(tmpdir(), "portfolio-evidence-"));