Files
Portfolio/tests/security/privacy-workers.test.ts

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,
);
});
});