feat: isolate privacy scanning and release signing
This commit is contained in:
240
tests/security/privacy-workers.test.ts
Normal file
240
tests/security/privacy-workers.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import {
|
||||
chmod,
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
readFile,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
// @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";
|
||||
|
||||
const { buildWorkerCommand, validateScanObjectDescriptor } = privacyRunner;
|
||||
|
||||
const h = (c: string) => c.repeat(64);
|
||||
|
||||
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("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 ordered heterogeneous agreement for every expected check", () => {
|
||||
const result = reconcileScan({
|
||||
expectedChecks: [{ inputDigest: h("1"), checkId: "bytes" }],
|
||||
reports: [report("worker-a"), report("worker-b")],
|
||||
matcherResults: [matcher()],
|
||||
candidate: {
|
||||
schema: "portfolio-candidate-release-manifest/v1",
|
||||
buildId: "r1",
|
||||
},
|
||||
});
|
||||
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 result = reconcileScan({
|
||||
expectedChecks: [{ inputDigest: h("1"), checkId: "bytes" }],
|
||||
reports: [report("worker-a"), report("worker-b")],
|
||||
matcherResults: [matcher()],
|
||||
candidate: {
|
||||
schema: "portfolio-candidate-release-manifest/v1",
|
||||
buildId: "r1",
|
||||
},
|
||||
});
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user