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-"));

View File

@@ -0,0 +1,79 @@
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import {
canonicalJsonDigest,
canonicalValueBytes,
} from "../../src/security/canonical-digest.ts";
import {
verifySignedRelease,
verifySignedReleaseForTest,
} from "../../scripts/deploy/lib/release-common.mjs";
const h = (c: string) => c.repeat(64);
describe("protected release verification authority", () => {
test("production seam rejects caller-selected verifier, policy path, and time", async () => {
await expect(
verifySignedRelease({
signedManifestPath: "/not-used",
authorityRoot: "/not-used",
verifierModulePath: "/tmp/evil.mjs",
trustPolicyPath: "/tmp/evil.json",
now: "2000-01-01T00:00:00Z",
protectedState: {},
} as never),
).rejects.toThrow(/unknown|caller|production/i);
});
test("test seam still requires the protected trust-policy digest", async () => {
const root = await mkdtemp(join(tmpdir(), "portfolio-release-authority-"));
await mkdir(join(root, ".control"));
const signedManifestPath = join(root, "signed.json");
const trustPolicyPath = join(root, ".control", "release-trust.json");
const trustPolicy = {
schema: "portfolio-trust-policy/v1",
policyVersion: "test",
};
await writeFile(
signedManifestPath,
canonicalValueBytes({ schema: "signed" }),
);
await writeFile(trustPolicyPath, canonicalValueBytes(trustPolicy));
const protectedState = {
latestLedgerHeadHash: h("1"),
latestLedgerRevision: 1,
currentManifestDigest: null,
publisherFence: 0,
releaseTrustPolicyDigest: h("9"),
};
await expect(
verifySignedReleaseForTest({
signedManifestPath,
trustPolicyPath,
protectedState,
now: "2026-09-02T01:00:00Z",
verifySignedReleaseEnvelope: () => {
throw new Error("must not reach verifier");
},
}),
).rejects.toThrow(/trust.policy|authority|digest/i);
protectedState.releaseTrustPolicyDigest = canonicalJsonDigest(
"portfolio-trust-policy/v1",
trustPolicy,
);
await expect(
verifySignedReleaseForTest({
signedManifestPath,
trustPolicyPath,
protectedState,
now: "2026-09-02T01:00:00Z",
verifySignedReleaseEnvelope: () => {
throw new Error("reached-bound-verifier");
},
}),
).rejects.toThrow("reached-bound-verifier");
});
});

View File

@@ -9,19 +9,22 @@ import {
canonicalValueBytes,
} from "../../src/security/canonical-digest.ts";
import { computeProductionInputRoot } from "../../src/security/production-input-root.ts";
import { APPROVAL_PROJECTION_ATTESTATION_SCOPE } from "../../src/security/approval-closure.ts";
// @ts-expect-error executable release modules intentionally have no browser declarations
import * as releaseSigner from "../../scripts/release/sign-release.mjs";
const {
signReleaseCandidate,
signReleaseCandidateForTest,
validateTrustPolicy,
verifySignedReleaseEnvelope,
verifySignedReleaseEnvelopeForTest,
} = releaseSigner;
const h = (c: string) => c.repeat(64);
const roles = [
["approval", "portfolio-approval-decision/v1"],
["projection", "portfolio-approval-projection/v1"],
["projection", APPROVAL_PROJECTION_ATTESTATION_SCOPE],
["privacyWorkload", "portfolio-privacy-workload/v1"],
["policy", "portfolio-policy-attestation/v1"],
["toolImage", "portfolio-tool-image-provenance/v1"],
@@ -91,6 +94,9 @@ function envelope(
...payload,
};
return {
...(role === "projection"
? { schema: "portfolio-approval-projection-attestation-envelope/v1" }
: {}),
payload: body,
signature: {
algorithm: "Ed25519",
@@ -142,12 +148,27 @@ function fixture() {
expiresAt: "2026-09-02T12:00:00Z",
minimumBoundExpiry: "2026-09-02T12:00:00Z",
};
const sourceClosure = {
schema: "portfolio-publication-closure/v1",
members: [],
};
const publicObjectClosure = {
schema: "portfolio-public-object-closure/v1",
members: [],
};
const candidate = {
schema: "portfolio-candidate-release-manifest/v1",
releaseManifest,
latestLedgerHeadHash: h("1"),
latestLedgerRevision: 7,
sourceClosureDigest: h("4"),
sourceClosureDigest: canonicalJsonDigest(
"portfolio-publication-closure/v1",
sourceClosure,
),
publicObjectClosureDigest: canonicalJsonDigest(
"portfolio-public-object-closure/v1",
publicObjectClosure,
),
publicRefMap: {},
lfsOids: [],
releaseArchives: [],
@@ -157,40 +178,40 @@ function fixture() {
};
const evidence = {
schema: "portfolio-scan-evidence/v1",
coverageCount: 1,
passedCount: 1,
checks: [
{
inputDigest: h("5"),
checkId: "bytes",
normalizedOutputDigest: h("6"),
normalizedMembers: [`bytes:${h("7")}`],
workerReports: [h("8"), h("9")],
matcherResult: h("a"),
ruleSetDigest: h("b"),
appliedRuleIds: ["denylist-v1"],
},
],
sourceClosureDigest: candidate.sourceClosureDigest,
publicObjectClosureDigest: candidate.publicObjectClosureDigest,
coverageCount: 0,
passedCount: 0,
checks: [],
};
const evidenceDigest = canonicalJsonDigest(
"portfolio-scan-evidence/v1",
evidence,
);
candidate.releaseManifest.scanEvidenceDigest = evidenceDigest;
const projection = envelope("projection", roles[1][1], keyPairs, {
approvedRecordRoot: h("9"),
approvedAssetRoot: h("e"),
approvalProofSetDigest: h("f"),
inputDigests,
productionInputRoot: releaseManifest.productionInputRoot,
liveLedgerHeadHash: h("1"),
liveLedgerRevision: 7,
policyVersion: "2026-09-02",
expiresAt: "2026-09-02T12:00:00Z",
earliestApprovalExpiry: "2026-09-02T12:00:00Z",
});
candidate.releaseManifest.approvalProjectionAttestationDigest =
canonicalJsonDigest(
"portfolio-approval-projection-attestation-envelope/v1",
projection,
);
const candidateDigest = canonicalJsonDigest(
"portfolio-candidate-release-manifest/v1",
candidate,
);
const attestations = {
projection: envelope("projection", roles[1][1], keyPairs, {
digest: h("2"),
productionInputRoot: releaseManifest.productionInputRoot,
approvedRecordRoot: h("9"),
approvedAssetRoot: h("e"),
approvalProofSetDigest: h("f"),
latestLedgerHeadHash: h("1"),
latestLedgerRevision: 7,
}),
projection,
privacyWorkload: envelope("privacyWorkload", roles[2][1], keyPairs, {
digest: h("2"),
candidateDigest,
@@ -207,6 +228,8 @@ function fixture() {
};
return {
keyPairs,
sourceClosure,
publicObjectClosure,
candidate,
evidence,
candidateDigest,
@@ -217,6 +240,54 @@ function fixture() {
}
describe("external scoped release signing", () => {
test("production signer/verifier reject caller-selected time and signer reconstructs exact source/public closure", async () => {
const f = fixture();
const dir = await mkdtemp(join(tmpdir(), "portfolio-release-key-"));
const keyPath = join(dir, "release.pem");
await writeFile(
keyPath,
f.keyPairs.release!.privateKey.export({ type: "pkcs8", format: "pem" }),
{ mode: 0o400 },
);
await expect(
signReleaseCandidate({
...f,
releaseKeyPath: keyPath,
now: "2000-01-01T00:00:00Z",
protectedState: {
latestLedgerHeadHash: h("1"),
latestLedgerRevision: 7,
currentManifestDigest: null,
publisherFence: 4,
},
}),
).rejects.toThrow(/now|unknown|caller/i);
const nonEmptyPublic = {
schema: "portfolio-public-object-closure/v1",
members: [
{
objectId: "private.txt",
inputDigest: h("d"),
requiredChecks: ["secret"],
},
],
};
await expect(
signReleaseCandidateForTest({
...f,
publicObjectClosure: nonEmptyPublic,
releaseKeyPath: keyPath,
now: "2026-09-02T01:00:00Z",
protectedState: {
latestLedgerHeadHash: h("1"),
latestLedgerRevision: 7,
currentManifestDigest: null,
publisherFence: 4,
},
}),
).rejects.toThrow(/closure|digest|non-empty/i);
});
test("signs exact canonical candidate bytes with an external Ed25519 key", async () => {
const f = fixture();
const dir = await mkdtemp(join(tmpdir(), "portfolio-release-key-"));
@@ -226,7 +297,7 @@ describe("external scoped release signing", () => {
f.keyPairs.release!.privateKey.export({ type: "pkcs8", format: "pem" }),
{ mode: 0o400 },
);
const result = await signReleaseCandidate({
const result = await signReleaseCandidateForTest({
...f,
releaseKeyPath: keyPath,
now: "2026-09-02T01:00:00Z",
@@ -247,7 +318,7 @@ describe("external scoped release signing", () => {
),
).toBe(true);
expect(
verifySignedReleaseEnvelope({
verifySignedReleaseEnvelopeForTest({
signedRelease: result,
trustPolicy: f.trustPolicy,
now: "2026-09-02T01:00:00Z",
@@ -285,14 +356,14 @@ describe("external scoped release signing", () => {
currentManifestDigest: null,
publisherFence: 4,
};
const signedRelease = await signReleaseCandidate({
const signedRelease = await signReleaseCandidateForTest({
...f,
releaseKeyPath: keyPath,
now: "2026-09-02T01:00:00Z",
protectedState: state,
});
expect(() =>
verifySignedReleaseEnvelope({
verifySignedReleaseEnvelopeForTest({
signedRelease,
trustPolicy: f.trustPolicy,
now: "2026-09-02T01:00:00Z",
@@ -300,7 +371,7 @@ describe("external scoped release signing", () => {
}),
).toThrow(/stale/i);
expect(() =>
verifySignedReleaseEnvelope({
verifySignedReleaseEnvelopeForTest({
signedRelease: {
...signedRelease,
payload: { ...signedRelease.payload, latestLedgerRevision: 8 },
@@ -315,7 +386,7 @@ describe("external scoped release signing", () => {
test("refuses repository-resident keys and non-external key material", async () => {
const f = fixture();
await expect(
signReleaseCandidate({
signReleaseCandidateForTest({
...f,
releaseKeyPath: join(process.cwd(), "release.pem"),
now: "2026-09-02T01:00:00Z",
@@ -424,7 +495,7 @@ describe("external scoped release signing", () => {
{ mode: 0o400 },
);
await expect(
signReleaseCandidate({
signReleaseCandidateForTest({
...f,
releaseKeyPath: keyPath,
now: "2026-09-02T01:00:00Z",