397 lines
13 KiB
TypeScript
397 lines
13 KiB
TypeScript
import {
|
|
chmod,
|
|
mkdtemp,
|
|
mkdir,
|
|
readFile,
|
|
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";
|
|
// @ts-expect-error executable release modules intentionally have no browser declarations
|
|
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,
|
|
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",
|
|
override: Record<string, unknown> = {},
|
|
) {
|
|
return {
|
|
schema: "portfolio-privacy-worker-report/v1",
|
|
workerId,
|
|
imageDigest: workerId === "worker-a" ? h("a") : h("b"),
|
|
toolchain: workerId === "worker-a" ? "bytes-js/v1" : "bytes-python/v1",
|
|
inputDigest: h("1"),
|
|
checkId: "bytes",
|
|
normalizedOutputDigest: h("2"),
|
|
normalizedMembers: ["text:" + h("3")],
|
|
verdict: "pass",
|
|
findingsDigest: h("0"),
|
|
...override,
|
|
};
|
|
}
|
|
|
|
function matcher(override: Record<string, unknown> = {}) {
|
|
return {
|
|
schema: "portfolio-privacy-matcher-result/v1",
|
|
inputDigest: h("1"),
|
|
checkId: "bytes",
|
|
normalizedOutputDigest: h("2"),
|
|
normalizedMembers: ["text:" + h("3")],
|
|
ruleSetDigest: h("4"),
|
|
appliedRuleIds: ["denylist-v1"],
|
|
verdict: "pass",
|
|
workerImageDigests: [h("a"), h("b")],
|
|
...override,
|
|
};
|
|
}
|
|
|
|
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"));
|
|
await writeFile(join(root, "z.txt"), "z");
|
|
await writeFile(join(root, "nested", "a.txt"), "a");
|
|
const closure = await enumerateClosure([{ class: "build", root }]);
|
|
expect(
|
|
closure.members.map((member: { path: string }) => member.path),
|
|
).toEqual(["nested/a.txt", "z.txt"]);
|
|
expect(
|
|
closure.members.every((member: { sha256: string }) =>
|
|
/^[a-f0-9]{64}$/u.test(member.sha256),
|
|
),
|
|
).toBe(true);
|
|
await symlink("z.txt", join(root, "link"));
|
|
await expect(enumerateClosure([{ class: "build", root }])).rejects.toThrow(
|
|
/symlink/i,
|
|
);
|
|
});
|
|
|
|
test("constructs networkless read-only non-root resource-limited jobs", () => {
|
|
const command = buildWorkerCommand({
|
|
runtime: "docker",
|
|
image: "scanner@sha256:" + h("a"),
|
|
inputPath: "/safe/input",
|
|
outputPath: "/safe/output",
|
|
worker: "worker-a",
|
|
});
|
|
expect(command).toContain("--network=none");
|
|
expect(command).toContain("--read-only");
|
|
expect(command).toContain("--cap-drop=ALL");
|
|
expect(command).toContain("--security-opt=no-new-privileges");
|
|
expect(command).toContain("--user=65532:65532");
|
|
expect(command).toContain("--memory=256m");
|
|
expect(command).toContain("--cpus=1");
|
|
expect(command).toContain("--pids-limit=64");
|
|
expect(command).toContain("--pull=never");
|
|
expect(command).toContain(
|
|
"--mount=type=bind,src=/safe/input,dst=/input,readonly",
|
|
);
|
|
expect(command).toContain("--mount=type=bind,src=/safe/output,dst=/output");
|
|
});
|
|
|
|
test.each([
|
|
["encrypted archive", { encrypted: true }],
|
|
["malformed container", { malformed: true }],
|
|
["nested archive", { archiveDepth: 4 }],
|
|
["archive expansion", { expandedBytes: 67_108_865 }],
|
|
["traversal", { members: [{ path: "../secret", entryType: "file" }] }],
|
|
["absolute member", { members: [{ path: "/secret", entryType: "file" }] }],
|
|
["symlink member", { members: [{ path: "safe", entryType: "symlink" }] }],
|
|
["device member", { members: [{ path: "safe", entryType: "device" }] }],
|
|
])("fails closed before decoding an unsafe %s", (_name, override) => {
|
|
expect(() =>
|
|
validateScanObjectDescriptor({
|
|
inputDigest: h("1"),
|
|
checkId: "container",
|
|
mediaType: "application/zip",
|
|
encrypted: false,
|
|
malformed: false,
|
|
archiveDepth: 1,
|
|
expandedBytes: 10,
|
|
members: [],
|
|
...override,
|
|
}),
|
|
).toThrow();
|
|
});
|
|
|
|
test("does not expose OCR or QR canary values when a matcher blocks them", () => {
|
|
const secret = "PRIVATE-CANARY-DO-NOT-PRINT";
|
|
expect(() =>
|
|
reconcileScan({
|
|
expectedChecks: [{ inputDigest: h("1"), checkId: "ocr" }],
|
|
reports: [
|
|
report("worker-a", { checkId: "ocr", verdict: "fail" }),
|
|
report("worker-b", { checkId: "ocr", verdict: "fail" }),
|
|
],
|
|
matcherResults: [matcher({ checkId: "ocr", verdict: "fail" })],
|
|
candidate: {
|
|
schema: "portfolio-candidate-release-manifest/v1",
|
|
buildId: "r1",
|
|
},
|
|
}),
|
|
).toThrowError(expect.not.stringContaining(secret));
|
|
});
|
|
|
|
test("accepts only the exact empty public object closure while scanners are unavailable", () => {
|
|
const source = sourceClosure();
|
|
const publicObjectClosure = publicClosure();
|
|
const result = reconcileScan({
|
|
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}$/);
|
|
expect(result.candidateDigest).toMatch(/^[a-f0-9]{64}$/);
|
|
expect(new TextDecoder().decode(result.evidenceBytes)).not.toContain(
|
|
"decoded-secret",
|
|
);
|
|
});
|
|
|
|
test.each([
|
|
["single worker", [report("worker-a")], [matcher()]],
|
|
[
|
|
"reordered workers",
|
|
[report("worker-b"), report("worker-a")],
|
|
[matcher()],
|
|
],
|
|
[
|
|
"schema-valid false pass",
|
|
[report("worker-a"), report("worker-b", { verdict: "fail" })],
|
|
[matcher()],
|
|
],
|
|
[
|
|
"normalized disagreement",
|
|
[
|
|
report("worker-a"),
|
|
report("worker-b", { normalizedOutputDigest: h("9") }),
|
|
],
|
|
[matcher()],
|
|
],
|
|
[
|
|
"substituted worker image",
|
|
[report("worker-a"), report("worker-b", { imageDigest: h("a") })],
|
|
[matcher()],
|
|
],
|
|
[
|
|
"mismatched check",
|
|
[report("worker-a"), report("worker-b", { checkId: "metadata" })],
|
|
[matcher()],
|
|
],
|
|
[
|
|
"matcher false pass",
|
|
[report("worker-a"), report("worker-b")],
|
|
[matcher({ normalizedOutputDigest: h("9") })],
|
|
],
|
|
[
|
|
"unsupported container",
|
|
[
|
|
report("worker-a", { verdict: "unsupported" }),
|
|
report("worker-b", { verdict: "unsupported" }),
|
|
],
|
|
[matcher({ verdict: "fail" })],
|
|
],
|
|
])("fails closed for %s", (_name, reports, matcherResults) => {
|
|
expect(() =>
|
|
reconcileScan({
|
|
expectedChecks: [{ inputDigest: h("1"), checkId: "bytes" }],
|
|
reports,
|
|
matcherResults,
|
|
candidate: {
|
|
schema: "portfolio-candidate-release-manifest/v1",
|
|
buildId: "r1",
|
|
},
|
|
}),
|
|
).toThrow();
|
|
});
|
|
|
|
test("writes canonical evidence and candidate objects content-addressed without overwrite", async () => {
|
|
const source = sourceClosure();
|
|
const publicObjectClosure = publicClosure();
|
|
const result = reconcileScan({
|
|
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-"));
|
|
await result.writeContentAddressed(root);
|
|
expect(await readFile(join(root, result.evidenceDigest), "utf8")).toBe(
|
|
new TextDecoder().decode(result.evidenceBytes),
|
|
);
|
|
await chmod(join(root, result.candidateDigest), 0o600);
|
|
await writeFile(join(root, result.candidateDigest), "substituted");
|
|
await expect(result.writeContentAddressed(root)).rejects.toThrow(
|
|
/immutable|content-addressed/i,
|
|
);
|
|
});
|
|
});
|