From 8592581b1872746258362ac2f2d2cdaf8050d9fa Mon Sep 17 00:00:00 2001 From: LucaP Date: Wed, 2 Sep 2026 10:07:01 +0300 Subject: [PATCH] feat: isolate privacy scanning and release signing --- containers/privacy-coordinator/Dockerfile | 11 + containers/privacy-matcher/Dockerfile | 8 + containers/privacy-matcher/matcher.mjs | 49 ++ containers/privacy-worker-a/Dockerfile | 8 + containers/privacy-worker-a/worker.mjs | 33 + containers/privacy-worker-b/Dockerfile | 8 + containers/privacy-worker-b/worker.py | 20 + scripts/release/enumerate-closure.mjs | 136 +++++ scripts/release/reconcile-scan.mjs | 282 +++++++++ scripts/release/run-privacy-workers.mjs | 115 ++++ scripts/release/sign-release.mjs | 700 ++++++++++++++++++++++ scripts/release/trust-policy.schema.json | 71 +++ tests/security/privacy-workers.test.ts | 240 ++++++++ tests/security/release-signing.test.ts | 441 ++++++++++++++ vitest.config.ts | 7 +- 15 files changed, 2128 insertions(+), 1 deletion(-) create mode 100644 containers/privacy-coordinator/Dockerfile create mode 100644 containers/privacy-matcher/Dockerfile create mode 100644 containers/privacy-matcher/matcher.mjs create mode 100644 containers/privacy-worker-a/Dockerfile create mode 100644 containers/privacy-worker-a/worker.mjs create mode 100644 containers/privacy-worker-b/Dockerfile create mode 100644 containers/privacy-worker-b/worker.py create mode 100644 scripts/release/enumerate-closure.mjs create mode 100644 scripts/release/reconcile-scan.mjs create mode 100644 scripts/release/run-privacy-workers.mjs create mode 100644 scripts/release/sign-release.mjs create mode 100644 scripts/release/trust-policy.schema.json create mode 100644 tests/security/privacy-workers.test.ts create mode 100644 tests/security/release-signing.test.ts diff --git a/containers/privacy-coordinator/Dockerfile b/containers/privacy-coordinator/Dockerfile new file mode 100644 index 0000000..e60d2c1 --- /dev/null +++ b/containers/privacy-coordinator/Dockerfile @@ -0,0 +1,11 @@ +ARG BASE_IMAGE +FROM ${BASE_IMAGE} +LABEL org.opencontainers.image.title="portfolio-privacy-coordinator" \ + org.opencontainers.image.description="Trusted closure reconciliation without raw decoder authority" +WORKDIR /opt/app +COPY package.json package-lock.json /opt/app/ +RUN npm ci --omit=dev --ignore-scripts +COPY scripts/release/reconcile-scan.mjs /opt/app/scripts/release/reconcile-scan.mjs +COPY src/security /opt/app/src/security +USER 65532:65532 +ENTRYPOINT ["node", "/opt/app/scripts/release/reconcile-scan.mjs", "--request", "/input/request.json"] diff --git a/containers/privacy-matcher/Dockerfile b/containers/privacy-matcher/Dockerfile new file mode 100644 index 0000000..ec2c34f --- /dev/null +++ b/containers/privacy-matcher/Dockerfile @@ -0,0 +1,8 @@ +ARG BASE_IMAGE +FROM ${BASE_IMAGE} +LABEL org.opencontainers.image.title="portfolio-privacy-matcher" \ + org.opencontainers.image.description="Decoder-free independent privacy evidence matcher" +WORKDIR /opt/matcher +COPY containers/privacy-matcher/matcher.mjs /opt/matcher/matcher.mjs +USER 65532:65532 +ENTRYPOINT ["node", "/opt/matcher/matcher.mjs"] diff --git a/containers/privacy-matcher/matcher.mjs b/containers/privacy-matcher/matcher.mjs new file mode 100644 index 0000000..64ef103 --- /dev/null +++ b/containers/privacy-matcher/matcher.mjs @@ -0,0 +1,49 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; + +const request = JSON.parse(await readFile("/input/request.json", "utf8")); +const [a, b] = request.reports ?? []; +if ( + !a || + !b || + a.workerId !== "worker-a" || + b.workerId !== "worker-b" || + a.imageDigest === b.imageDigest || + a.toolchain === b.toolchain || + a.inputDigest !== b.inputDigest || + a.checkId !== b.checkId || + a.normalizedOutputDigest !== b.normalizedOutputDigest || + JSON.stringify(a.normalizedMembers) !== JSON.stringify(b.normalizedMembers) || + a.verdict !== "pass" || + b.verdict !== "pass" +) + throw new Error("Independent worker evidence does not agree."); +const rules = request.rules; +if (!Array.isArray(rules) || rules.length === 0) + throw new Error("Matcher rules are required."); +const appliedRuleIds = rules + .map((rule) => rule.id) + .sort((left, right) => Buffer.from(left).compare(Buffer.from(right))); +if ( + new Set(appliedRuleIds).size !== appliedRuleIds.length || + rules.some((rule) => !Array.isArray(rule.deniedMemberDigests)) +) + throw new Error("Matcher rules are invalid."); +const denied = new Set(rules.flatMap((rule) => rule.deniedMemberDigests)); +if (a.normalizedMembers.some((member) => denied.has(member))) + throw new Error("Privacy rule matched normalized evidence."); +const result = { + schema: "portfolio-privacy-matcher-result/v1", + inputDigest: a.inputDigest, + checkId: a.checkId, + normalizedOutputDigest: a.normalizedOutputDigest, + normalizedMembers: a.normalizedMembers, + ruleSetDigest: request.ruleSetDigest, + appliedRuleIds, + verdict: "pass", + workerImageDigests: [a.imageDigest, b.imageDigest], +}; +await writeFile("/output/result.json", JSON.stringify(result), { + flag: "wx", + mode: 0o400, +}); diff --git a/containers/privacy-worker-a/Dockerfile b/containers/privacy-worker-a/Dockerfile new file mode 100644 index 0000000..ca83adf --- /dev/null +++ b/containers/privacy-worker-a/Dockerfile @@ -0,0 +1,8 @@ +ARG BASE_IMAGE +FROM ${BASE_IMAGE} +LABEL org.opencontainers.image.title="portfolio-privacy-worker-a" \ + org.opencontainers.image.description="Independent byte-oriented Node privacy scanner" +WORKDIR /opt/scanner +COPY containers/privacy-worker-a/worker.mjs /opt/scanner/worker.mjs +USER 65532:65532 +ENTRYPOINT ["node", "/opt/scanner/worker.mjs"] diff --git a/containers/privacy-worker-a/worker.mjs b/containers/privacy-worker-a/worker.mjs new file mode 100644 index 0000000..fbc6276 --- /dev/null +++ b/containers/privacy-worker-a/worker.mjs @@ -0,0 +1,33 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; + +const request = JSON.parse(await readFile("/input/request.json", "utf8")); +if ( + request.schema !== "portfolio-privacy-worker-request/v1" || + request.workerId !== "worker-a" || + !/^[a-f0-9]{64}$/u.test(request.inputDigest) +) + throw new Error("Invalid worker request."); +const bytes = await readFile("/input/object"); +const actual = createHash("sha256").update(bytes).digest("hex"); +if (actual !== request.inputDigest) throw new Error("Input digest mismatch."); +const normalizedMembers = [`bytes:${actual}`]; +const normalizedOutputDigest = createHash("sha256") + .update(Buffer.from(JSON.stringify(normalizedMembers))) + .digest("hex"); +const output = { + schema: "portfolio-privacy-worker-report/v1", + workerId: "worker-a", + imageDigest: request.imageDigest, + toolchain: "bytes-js/v1", + inputDigest: actual, + checkId: request.checkId, + normalizedOutputDigest, + normalizedMembers, + verdict: "pass", + findingsDigest: "0".repeat(64), +}; +await writeFile("/output/report.json", JSON.stringify(output), { + flag: "wx", + mode: 0o400, +}); diff --git a/containers/privacy-worker-b/Dockerfile b/containers/privacy-worker-b/Dockerfile new file mode 100644 index 0000000..b985a93 --- /dev/null +++ b/containers/privacy-worker-b/Dockerfile @@ -0,0 +1,8 @@ +ARG BASE_IMAGE +FROM ${BASE_IMAGE} +LABEL org.opencontainers.image.title="portfolio-privacy-worker-b" \ + org.opencontainers.image.description="Independent byte-oriented Python privacy scanner" +WORKDIR /opt/scanner +COPY containers/privacy-worker-b/worker.py /opt/scanner/worker.py +USER 65532:65532 +ENTRYPOINT ["python3", "/opt/scanner/worker.py"] diff --git a/containers/privacy-worker-b/worker.py b/containers/privacy-worker-b/worker.py new file mode 100644 index 0000000..fd67a87 --- /dev/null +++ b/containers/privacy-worker-b/worker.py @@ -0,0 +1,20 @@ +import hashlib +import json + +with open('/input/request.json', 'r', encoding='utf-8') as handle: + request = json.load(handle) +if request.get('schema') != 'portfolio-privacy-worker-request/v1' or request.get('workerId') != 'worker-b': + raise ValueError('Invalid worker request.') +with open('/input/object', 'rb') as handle: + payload = handle.read() +actual = hashlib.sha256(payload).hexdigest() +if actual != request.get('inputDigest'): + raise ValueError('Input digest mismatch.') +members = ['bytes:' + actual] +normalized = json.dumps(members, separators=(',', ':')).encode('utf-8') +output = {'schema': 'portfolio-privacy-worker-report/v1', 'workerId': 'worker-b', 'imageDigest': request['imageDigest'], + 'toolchain': 'bytes-python/v1', 'inputDigest': actual, 'checkId': request['checkId'], + 'normalizedOutputDigest': hashlib.sha256(normalized).hexdigest(), 'normalizedMembers': members, + 'verdict': 'pass', 'findingsDigest': '0' * 64} +with open('/output/report.json', 'x', encoding='utf-8') as handle: + json.dump(output, handle, separators=(',', ':')) diff --git a/scripts/release/enumerate-closure.mjs b/scripts/release/enumerate-closure.mjs new file mode 100644 index 0000000..067c389 --- /dev/null +++ b/scripts/release/enumerate-closure.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { lstat, readFile, readdir, realpath } from "node:fs/promises"; +import { relative, resolve, sep } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { + canonicalJsonDigest, + canonicalValueBytes, +} from "../../src/security/canonical-digest.ts"; + +const validClass = + /^(?:git-object|git-ref|lfs|source|build|precompressed|release-archive)$/u; +const byteCompare = (left, right) => + Buffer.from(left).compare(Buffer.from(right)); + +function normalizedRelativePath(root, path) { + const value = relative(root, path).split(sep).join("/"); + if ( + value === "" || + value.startsWith("../") || + value.startsWith("/") || + value.includes("/../") || + value !== value.normalize("NFC") + ) { + throw new Error( + "Closure member path is outside the root or non-canonical.", + ); + } + return value; +} + +async function walk(root, current, members, memberClass) { + const entries = await readdir(current, { withFileTypes: true }); + entries.sort((a, b) => byteCompare(a.name, b.name)); + for (const entry of entries) { + if ( + entry.name !== entry.name.normalize("NFC") || + entry.name === "." || + entry.name === ".." + ) + throw new Error("Closure path is non-canonical."); + const path = resolve(current, entry.name); + const info = await lstat(path); + if (info.isSymbolicLink()) + throw new Error("Closure cannot contain a symlink."); + if (info.isDirectory()) { + await walk(root, path, members, memberClass); + continue; + } + if (!info.isFile()) + throw new Error( + "Closure cannot contain device, socket, FIFO, or other special entries.", + ); + if (info.nlink !== 1) + throw new Error("Closure cannot contain hard-link aliases."); + const bytes = await readFile(path); + members.push({ + class: memberClass, + path: normalizedRelativePath(root, path), + bytes: bytes.byteLength, + sha256: createHash("sha256").update(bytes).digest("hex"), + }); + } +} + +export async function enumerateClosure(inputs) { + if (!Array.isArray(inputs) || inputs.length === 0) + throw new Error("At least one explicit closure root is required."); + const members = []; + const roots = new Set(); + for (const input of inputs) { + if ( + input === null || + typeof input !== "object" || + !validClass.test(input.class) || + typeof input.root !== "string" + ) { + throw new Error("Closure root descriptor is invalid."); + } + const root = await realpath(input.root); + if (roots.has(`${input.class}\0${root}`)) + throw new Error("Duplicate closure root."); + roots.add(`${input.class}\0${root}`); + const info = await lstat(root); + if (!info.isDirectory() || info.isSymbolicLink()) + throw new Error("Closure root must be a real directory."); + await walk(root, root, members, input.class); + } + members.sort((a, b) => + byteCompare(`${a.class}\0${a.path}`, `${b.class}\0${b.path}`), + ); + const identities = new Set(); + const folded = new Set(); + for (const member of members) { + const identity = `${member.class}\0${member.path}`; + const fold = identity.toLocaleLowerCase("en-US"); + if (identities.has(identity) || folded.has(fold)) + throw new Error("Closure contains a duplicate or case-fold collision."); + identities.add(identity); + folded.add(fold); + } + const value = { schema: "portfolio-publication-closure/v1", members }; + return { + ...value, + digest: canonicalJsonDigest("portfolio-publication-closure/v1", value), + }; +} + +async function main() { + const descriptors = process.argv.slice(2).map((argument) => { + const separator = argument.indexOf("="); + if (separator < 1) + throw new Error("Usage: enumerate-closure.mjs = [...]"); + return { + class: argument.slice(0, separator), + root: argument.slice(separator + 1), + }; + }); + process.stdout.write( + Buffer.from(canonicalValueBytes(await enumerateClosure(descriptors))), + ); + process.stdout.write("\n"); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main().catch((error) => { + process.stderr.write( + `Closure enumeration failed: ${error instanceof Error ? error.message : "unknown error"}\n`, + ); + process.exitCode = 1; + }); +} diff --git a/scripts/release/reconcile-scan.mjs b/scripts/release/reconcile-scan.mjs new file mode 100644 index 0000000..4413355 --- /dev/null +++ b/scripts/release/reconcile-scan.mjs @@ -0,0 +1,282 @@ +#!/usr/bin/env node +import { mkdir, open, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { + canonicalJsonDigest, + canonicalValueBytes, + isSha256Hex, + parseCanonicalJson, +} from "../../src/security/canonical-digest.ts"; + +const workerFields = [ + "schema", + "workerId", + "imageDigest", + "toolchain", + "inputDigest", + "checkId", + "normalizedOutputDigest", + "normalizedMembers", + "verdict", + "findingsDigest", +]; +const matcherFields = [ + "schema", + "inputDigest", + "checkId", + "normalizedOutputDigest", + "normalizedMembers", + "ruleSetDigest", + "appliedRuleIds", + "verdict", + "workerImageDigests", +]; +const safeToken = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/u; + +function object(value, label) { + if (value === null || typeof value !== "object" || Array.isArray(value)) + throw new Error(`${label} must be an object.`); + return value; +} +function exact(value, fields, label) { + if ( + JSON.stringify(Object.keys(value).sort()) !== + JSON.stringify([...fields].sort()) + ) + throw new Error(`${label} contains missing or extra fields.`); +} +function stringsEqual(left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} +function validateMembers(value) { + if ( + !Array.isArray(value) || + value.some( + (member) => + typeof member !== "string" || + member !== member.normalize("NFC") || + !/^[a-z][a-z0-9-]*:[a-f0-9]{64}$/u.test(member), + ) + ) + throw new Error("Normalized members must contain typed digests only."); + const sorted = [...value].sort((a, b) => + Buffer.from(a).compare(Buffer.from(b)), + ); + if (!stringsEqual(value, sorted) || new Set(value).size !== value.length) + throw new Error("Normalized members must be unique and byte sorted."); +} +function validateWorker(value, expectedWorker) { + const report = object(value, "worker report"); + exact(report, workerFields, "worker report"); + if ( + report.schema !== "portfolio-privacy-worker-report/v1" || + report.workerId !== expectedWorker || + !isSha256Hex(report.imageDigest) || + !safeToken.test(report.toolchain) || + !isSha256Hex(report.inputDigest) || + !safeToken.test(report.checkId) || + !isSha256Hex(report.normalizedOutputDigest) || + !isSha256Hex(report.findingsDigest) || + !["pass", "fail", "unsupported"].includes(report.verdict) + ) + throw new Error("Worker report is invalid."); + validateMembers(report.normalizedMembers); + return report; +} +function validateMatcher(value) { + const result = object(value, "matcher result"); + exact(result, matcherFields, "matcher result"); + if ( + result.schema !== "portfolio-privacy-matcher-result/v1" || + !isSha256Hex(result.inputDigest) || + !safeToken.test(result.checkId) || + !isSha256Hex(result.normalizedOutputDigest) || + !isSha256Hex(result.ruleSetDigest) || + !["pass", "fail"].includes(result.verdict) || + !Array.isArray(result.workerImageDigests) || + result.workerImageDigests.length !== 2 || + result.workerImageDigests.some((v) => !isSha256Hex(v)) + ) + throw new Error("Matcher result is invalid."); + validateMembers(result.normalizedMembers); + if ( + !Array.isArray(result.appliedRuleIds) || + result.appliedRuleIds.length === 0 || + result.appliedRuleIds.some((value) => !safeToken.test(value)) || + new Set(result.appliedRuleIds).size !== result.appliedRuleIds.length || + !stringsEqual( + result.appliedRuleIds, + [...result.appliedRuleIds].sort((a, b) => + Buffer.from(a).compare(Buffer.from(b)), + ), + ) + ) + throw new Error("Matcher rule IDs must be unique and byte sorted."); + return result; +} +async function writeOnce(root, digest, bytes) { + await mkdir(root, { recursive: true }); + const path = join(root, digest); + try { + const handle = await open(path, "wx", 0o400); + try { + await handle.writeFile(bytes); + await handle.sync(); + } finally { + await handle.close(); + } + } catch (error) { + if (error && error.code === "EEXIST") { + const existing = await readFile(path); + if (Buffer.compare(existing, Buffer.from(bytes)) !== 0) + throw new Error( + "Content-addressed object is immutable and differs from existing bytes.", + ); + return; + } + throw error; + } +} + +export function reconcileScan({ + expectedChecks, + reports, + matcherResults, + candidate, +}) { + if ( + !Array.isArray(expectedChecks) || + !Array.isArray(reports) || + !Array.isArray(matcherResults) || + expectedChecks.length === 0 + ) + throw new Error("Reconciliation input is invalid."); + const ordered = [...expectedChecks].sort((a, b) => + Buffer.from(`${a.inputDigest}\0${a.checkId}`).compare( + Buffer.from(`${b.inputDigest}\0${b.checkId}`), + ), + ); + if ( + !stringsEqual(expectedChecks, ordered) || + new Set(expectedChecks.map((v) => `${v.inputDigest}\0${v.checkId}`)) + .size !== expectedChecks.length + ) + throw new Error("Expected check closure must be unique and byte sorted."); + if ( + reports.length !== expectedChecks.length * 2 || + matcherResults.length !== expectedChecks.length + ) + throw new Error("Missing or extra privacy evidence."); + const checks = []; + expectedChecks.forEach((expected, index) => { + if (!isSha256Hex(expected.inputDigest) || !safeToken.test(expected.checkId)) + throw new Error("Expected check is invalid."); + const a = validateWorker(reports[index * 2], "worker-a"); + const b = validateWorker(reports[index * 2 + 1], "worker-b"); + const match = validateMatcher(matcherResults[index]); + for (const item of [a, b, match]) + if ( + item.inputDigest !== expected.inputDigest || + item.checkId !== expected.checkId + ) + throw new Error( + "Privacy evidence is missing, reordered, or bound to another check.", + ); + if (a.imageDigest === b.imageDigest || a.toolchain === b.toolchain) + throw new Error("Privacy workers are not heterogeneous."); + if ( + a.verdict !== "pass" || + b.verdict !== "pass" || + match.verdict !== "pass" + ) + throw new Error("Every worker and matcher must independently pass."); + if ( + a.normalizedOutputDigest !== b.normalizedOutputDigest || + a.normalizedOutputDigest !== match.normalizedOutputDigest || + !stringsEqual(a.normalizedMembers, b.normalizedMembers) || + !stringsEqual(a.normalizedMembers, match.normalizedMembers) || + !stringsEqual(match.workerImageDigests, [a.imageDigest, b.imageDigest]) + ) + throw new Error("Independent normalized evidence disagrees."); + checks.push({ + inputDigest: expected.inputDigest, + checkId: expected.checkId, + normalizedOutputDigest: a.normalizedOutputDigest, + normalizedMembers: a.normalizedMembers, + workerReports: [ + canonicalJsonDigest("portfolio-privacy-worker-report/v1", a), + canonicalJsonDigest("portfolio-privacy-worker-report/v1", b), + ], + matcherResult: canonicalJsonDigest( + "portfolio-privacy-matcher-result/v1", + match, + ), + ruleSetDigest: match.ruleSetDigest, + appliedRuleIds: match.appliedRuleIds, + }); + }); + const evidence = { + schema: "portfolio-scan-evidence/v1", + coverageCount: checks.length, + passedCount: checks.length, + checks, + }; + const evidenceBytes = canonicalValueBytes(evidence); + const evidenceDigest = canonicalJsonDigest( + "portfolio-scan-evidence/v1", + evidence, + ); + const candidateRecord = object(candidate, "candidate"); + if (candidateRecord.schema !== "portfolio-candidate-release-manifest/v1") + throw new Error("Candidate schema is invalid."); + const candidateBytes = canonicalValueBytes(candidateRecord); + const candidateDigest = canonicalJsonDigest( + "portfolio-candidate-release-manifest/v1", + candidateRecord, + ); + return { + evidence, + evidenceBytes, + evidenceDigest, + candidate: candidateRecord, + candidateBytes, + candidateDigest, + async writeContentAddressed(root) { + await writeOnce(root, evidenceDigest, evidenceBytes); + await writeOnce(root, candidateDigest, candidateBytes); + }, + }; +} + +async function main() { + if (process.argv.length !== 4 || process.argv[2] !== "--request") + throw new Error( + "Usage: reconcile-scan.mjs --request ", + ); + const request = parseCanonicalJson(await readFile(process.argv[3])); + const result = reconcileScan(request); + process.stdout.write( + Buffer.from( + canonicalValueBytes({ + schema: "portfolio-reconciliation-result/v1", + evidenceDigest: result.evidenceDigest, + candidateDigest: result.candidateDigest, + }), + ), + ); + process.stdout.write("\n"); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main().catch((error) => { + process.stderr.write( + `Privacy reconciliation failed: ${error instanceof Error ? error.message : "unknown error"}\n`, + ); + process.exitCode = 1; + }); +} diff --git a/scripts/release/run-privacy-workers.mjs b/scripts/release/run-privacy-workers.mjs new file mode 100644 index 0000000..14a9d53 --- /dev/null +++ b/scripts/release/run-privacy-workers.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +import { pathToFileURL } from "node:url"; + +const safeImage = /^[A-Za-z0-9._/-]+@sha256:[a-f0-9]{64}$/u; +const safePath = /^\/[A-Za-z0-9._/-]+$/u; + +export function buildWorkerCommand({ + runtime, + image, + inputPath, + outputPath, + worker, +}) { + if (runtime !== "docker" && runtime !== "podman") + throw new Error("Only Docker or Podman isolation runtimes are supported."); + if (!safeImage.test(image)) + throw new Error( + "Worker image must be pinned by a lowercase SHA-256 digest.", + ); + if ( + !safePath.test(inputPath) || + !safePath.test(outputPath) || + inputPath.includes("..") || + outputPath.includes("..") + ) + throw new Error("Worker mounts must be explicit absolute safe paths."); + if ( + worker !== "worker-a" && + worker !== "worker-b" && + worker !== "matcher" && + worker !== "coordinator" + ) + throw new Error("Unknown isolated workload."); + return [ + runtime, + "run", + "--rm", + "--pull=never", + "--network=none", + "--read-only", + "--cap-drop=ALL", + "--security-opt=no-new-privileges", + "--user=65532:65532", + "--memory=256m", + "--memory-swap=256m", + "--cpus=1", + "--pids-limit=64", + "--tmpfs=/tmp:rw,noexec,nosuid,nodev,size=64m", + `--mount=type=bind,src=${inputPath},dst=/input,readonly`, + `--mount=type=bind,src=${outputPath},dst=/output`, + image, + ]; +} + +export function validateScanObjectDescriptor(value) { + if ( + value === null || + typeof value !== "object" || + !/^[a-f0-9]{64}$/u.test(value.inputDigest) || + !/^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/u.test(value.checkId) || + typeof value.mediaType !== "string" + ) + throw new Error("Scan object descriptor is invalid."); + if (value.encrypted === true) + throw new Error("Encrypted containers are unsupported and fail closed."); + if (value.malformed === true) + throw new Error("Malformed containers fail closed."); + if ( + !Number.isSafeInteger(value.archiveDepth) || + value.archiveDepth < 0 || + value.archiveDepth > 3 + ) + throw new Error("Nested archive depth exceeds the limit."); + if ( + !Number.isSafeInteger(value.expandedBytes) || + value.expandedBytes < 0 || + value.expandedBytes > 64 * 1024 * 1024 + ) + throw new Error("Expanded archive size exceeds the limit."); + if (!Array.isArray(value.members)) + throw new Error("Container member inventory is required."); + for (const member of value.members) { + if ( + member === null || + typeof member !== "object" || + typeof member.path !== "string" || + member.path.startsWith("/") || + member.path.includes("\\") || + member.path.split("/").some((part) => part === ".." || part === ".") || + member.path !== member.path.normalize("NFC") || + !["file", "directory"].includes(member.entryType) + ) { + throw new Error( + "Archive traversal, symlink, hard-link, device, or non-canonical member rejected.", + ); + } + } + return value; +} + +export function liveContainerStatus() { + return { + schema: "portfolio-live-container-validation/v1", + status: "pending", + reason: + "Requires locally cached, provenance-verified pinned images; network pulls are prohibited.", + }; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + process.stdout.write(`${JSON.stringify(liveContainerStatus())}\n`); +} diff --git a/scripts/release/sign-release.mjs b/scripts/release/sign-release.mjs new file mode 100644 index 0000000..e294e43 --- /dev/null +++ b/scripts/release/sign-release.mjs @@ -0,0 +1,700 @@ +#!/usr/bin/env node +import { createPrivateKey, createPublicKey, sign, verify } from "node:crypto"; +import { readFile, realpath, stat as fileStat } from "node:fs/promises"; +import { isAbsolute, relative, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { + canonicalJsonDigest, + canonicalValueBytes, + isSha256Hex, + parseCanonicalJson, +} from "../../src/security/canonical-digest.ts"; +import { validateReleaseManifest } from "../../src/security/release-manifest.ts"; + +const rootContract = { + approval: { + scope: "portfolio-approval-decision/v1", + recovery: ["freeze-publication", "audit-checkpoint", "fresh-approvals"], + }, + projection: { + scope: "portfolio-approval-projection/v1", + recovery: ["freeze-publication", "revoke-epoch", "fresh-evidence"], + }, + privacyWorkload: { + scope: "portfolio-privacy-workload/v1", + recovery: ["freeze-publication", "revoke-epoch", "fresh-evidence"], + }, + policy: { + scope: "portfolio-policy-attestation/v1", + recovery: ["freeze-publication", "revoke-epoch", "fresh-evidence"], + }, + toolImage: { + scope: "portfolio-tool-image-provenance/v1", + recovery: ["freeze-publication", "revoke-epoch", "fresh-evidence"], + }, + release: { + scope: "portfolio-release-signing/v1", + recovery: ["freeze-publication", "revoke-epoch", "fresh-evidence"], + }, + publisher: { + scope: "portfolio-publisher-fencing/v1", + recovery: [ + "freeze-publication", + "rotate-credential", + "advance-fence", + "reconcile-staging", + ], + }, +}; +const rootFields = [ + "keyId", + "issuerEpoch", + "scope", + "publicKeyPem", + "revokedEpochs", + "compromiseResponse", +]; +const policyFields = ["schema", "policyVersion", "publicationFrozen", "roots"]; +const candidateFields = [ + "schema", + "releaseManifest", + "latestLedgerHeadHash", + "latestLedgerRevision", + "sourceClosureDigest", + "publicRefMap", + "lfsOids", + "releaseArchives", + "buildProvenanceDigest", + "privacyPolicyDigest", + "toolImageDigests", +]; +const signatureFields = [ + "algorithm", + "encoding", + "keyId", + "issuerEpoch", + "scope", + "value", +]; +const attestationPayloadFields = { + projection: [ + "schema", + "keyId", + "issuerEpoch", + "scope", + "issuedAt", + "expiresAt", + "digest", + "productionInputRoot", + "approvedRecordRoot", + "approvedAssetRoot", + "approvalProofSetDigest", + "latestLedgerHeadHash", + "latestLedgerRevision", + ], + privacyWorkload: [ + "schema", + "keyId", + "issuerEpoch", + "scope", + "issuedAt", + "expiresAt", + "digest", + "candidateDigest", + "evidenceDigest", + ], + policy: [ + "schema", + "keyId", + "issuerEpoch", + "scope", + "issuedAt", + "expiresAt", + "digest", + "policyVersion", + ], + toolImage: [ + "schema", + "keyId", + "issuerEpoch", + "scope", + "issuedAt", + "expiresAt", + "digest", + "imageDigests", + ], +}; +const signedFields = ["schema", "payload", "payloadDigest", "signature"]; +const timestamp = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u; +const safeToken = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/u; + +function object(value, label) { + if (value === null || typeof value !== "object" || Array.isArray(value)) + throw new Error(`${label} must be an object.`); + return value; +} +function exact(value, fields, label) { + if ( + JSON.stringify(Object.keys(value).sort()) !== + JSON.stringify([...fields].sort()) + ) + throw new Error(`${label} contains missing or unknown fields.`); +} +function digest(value, label) { + if (!isSha256Hex(value)) + throw new Error(`${label} is not a canonical SHA-256 digest.`); +} +function compare(left, right) { + return Buffer.from(left).compare(Buffer.from(right)); +} + +export function validateTrustPolicy(value) { + const policy = object(value, "trust policy"); + exact(policy, policyFields, "trust policy"); + if ( + policy.schema !== "portfolio-trust-policy/v1" || + typeof policy.policyVersion !== "string" || + !safeToken.test(policy.policyVersion) || + typeof policy.publicationFrozen !== "boolean" + ) + throw new Error("Trust policy metadata is invalid."); + const roots = object(policy.roots, "trust roots"); + exact(roots, Object.keys(rootContract), "trust roots"); + const seenKeys = new Set(); + const seenPublicKeys = new Set(); + for (const [role, contract] of Object.entries(rootContract)) { + const root = object(roots[role], `${role} root`); + exact(root, rootFields, `${role} root`); + if ( + typeof root.keyId !== "string" || + !safeToken.test(root.keyId) || + seenKeys.has(root.keyId) || + !Number.isSafeInteger(root.issuerEpoch) || + root.issuerEpoch < 1 + ) + throw new Error(`${role} key ID or issuer epoch is invalid.`); + seenKeys.add(root.keyId); + if (root.scope !== contract.scope) + throw new Error(`${role} trust-root scope is invalid.`); + if (typeof root.publicKeyPem !== "string") + throw new Error(`${role} public key is invalid.`); + const key = createPublicKey(root.publicKeyPem); + if (key.asymmetricKeyType !== "ed25519") + throw new Error(`${role} key must be Ed25519.`); + const publicFingerprint = key + .export({ type: "spki", format: "der" }) + .toString("base64"); + if (seenPublicKeys.has(publicFingerprint)) + throw new Error("Trust roots must use distinct protected keys."); + seenPublicKeys.add(publicFingerprint); + if ( + !Array.isArray(root.revokedEpochs) || + root.revokedEpochs.some( + (epoch) => !Number.isSafeInteger(epoch) || epoch < 1, + ) || + new Set(root.revokedEpochs).size !== root.revokedEpochs.length + ) + throw new Error(`${role} revocation list is invalid.`); + if (root.revokedEpochs.includes(root.issuerEpoch)) + throw new Error(`${role} current issuer epoch is revoked.`); + if ( + !Array.isArray(root.compromiseResponse) || + contract.recovery.some( + (action) => !root.compromiseResponse.includes(action), + ) + ) { + const detail = + role === "approval" + ? "checkpoint audit and fresh approvals" + : role === "publisher" + ? "fence advance and staging reconciliation" + : "fresh evidence"; + throw new Error(`${role} compromise response lacks ${detail}.`); + } + } + return policy; +} + +function verifyAttestation(value, root, expectedSchema, role, now) { + const envelope = object(value, "attestation"); + exact(envelope, ["payload", "signature"], "attestation"); + const payload = object(envelope.payload, "attestation payload"); + exact(payload, attestationPayloadFields[role], "attestation payload"); + const signature = object(envelope.signature, "attestation signature"); + exact(signature, ["algorithm", "encoding", "value"], "attestation signature"); + if ( + payload.schema !== expectedSchema || + payload.keyId !== root.keyId || + payload.issuerEpoch !== root.issuerEpoch || + payload.scope !== root.scope + ) + throw new Error( + "Attestation schema, scope, key, or issuer epoch is not trusted.", + ); + if ( + typeof payload.issuedAt !== "string" || + typeof payload.expiresAt !== "string" || + !timestamp.test(payload.issuedAt) || + !timestamp.test(payload.expiresAt) || + Date.parse(payload.issuedAt) > Date.parse(now) || + Date.parse(now) >= Date.parse(payload.expiresAt) + ) + throw new Error("Attestation is not current at trusted time."); + for (const field of [ + "digest", + "productionInputRoot", + "approvedRecordRoot", + "approvedAssetRoot", + "approvalProofSetDigest", + "latestLedgerHeadHash", + "candidateDigest", + "evidenceDigest", + ]) + if (field in payload) digest(payload[field], `attestation ${field}`); + if ( + "latestLedgerRevision" in payload && + (!Number.isSafeInteger(payload.latestLedgerRevision) || + payload.latestLedgerRevision < 0) + ) + throw new Error("Attestation ledger revision is invalid."); + if ( + "imageDigests" in payload && + (!Array.isArray(payload.imageDigests) || + payload.imageDigests.length < 2 || + payload.imageDigests.some((item) => !isSha256Hex(item))) + ) + throw new Error("Attested image digests are invalid."); + if ( + signature.algorithm !== "Ed25519" || + signature.encoding !== "base64url-nopad" || + typeof signature.value !== "string" || + !/^[A-Za-z0-9_-]{86}$/u.test(signature.value) + ) + throw new Error("Attestation signature metadata is invalid."); + const bytes = Buffer.from(signature.value, "base64url"); + if ( + bytes.toString("base64url") !== signature.value || + !verify( + null, + canonicalValueBytes(payload), + createPublicKey(root.publicKeyPem), + bytes, + ) + ) + throw new Error("Attestation signature is invalid."); + return payload; +} + +function validateCandidate(value, now) { + const candidate = object(value, "candidate"); + exact(candidate, candidateFields, "candidate"); + if (candidate.schema !== "portfolio-candidate-release-manifest/v1") + throw new Error("Candidate schema is invalid."); + digest(candidate.latestLedgerHeadHash, "latestLedgerHeadHash"); + digest(candidate.sourceClosureDigest, "sourceClosureDigest"); + digest(candidate.buildProvenanceDigest, "buildProvenanceDigest"); + digest(candidate.privacyPolicyDigest, "privacyPolicyDigest"); + if ( + !Number.isSafeInteger(candidate.latestLedgerRevision) || + candidate.latestLedgerRevision < 0 + ) + throw new Error("latestLedgerRevision is invalid."); + const refs = object(candidate.publicRefMap, "publicRefMap"); + for (const [name, hash] of Object.entries(refs)) + if (!safeToken.test(name) || !/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(hash)) + throw new Error("Public ref map is invalid."); + if ( + !Array.isArray(candidate.lfsOids) || + candidate.lfsOids.some((oid) => !/^sha256:[a-f0-9]{64}$/u.test(oid)) || + JSON.stringify(candidate.lfsOids) !== + JSON.stringify([...candidate.lfsOids].sort(compare)) || + new Set(candidate.lfsOids).size !== candidate.lfsOids.length + ) + throw new Error("LFS closure is invalid."); + if (!Array.isArray(candidate.releaseArchives)) + throw new Error("Release archive closure is invalid."); + for (const archive of candidate.releaseArchives) { + const record = object(archive, "release archive"); + exact(record, ["path", "sha256"], "release archive"); + if ( + typeof record.path !== "string" || + record.path.startsWith("/") || + record.path.includes("..") + ) + throw new Error("Release archive path is invalid."); + digest(record.sha256, "release archive digest"); + } + const archiveKeys = candidate.releaseArchives.map( + (archive) => `${archive.path}\0${archive.sha256}`, + ); + if ( + new Set(archiveKeys).size !== archiveKeys.length || + JSON.stringify(archiveKeys) !== + JSON.stringify([...archiveKeys].sort(compare)) + ) + throw new Error("Release archive closure must be unique and byte sorted."); + if ( + !Array.isArray(candidate.toolImageDigests) || + candidate.toolImageDigests.length < 2 || + candidate.toolImageDigests.some((value) => !isSha256Hex(value)) || + new Set(candidate.toolImageDigests).size !== + candidate.toolImageDigests.length || + JSON.stringify(candidate.toolImageDigests) !== + JSON.stringify([...candidate.toolImageDigests].sort(compare)) + ) + throw new Error("Tool image closure must contain unique, sorted digests."); + const releaseManifest = validateReleaseManifest(candidate.releaseManifest, { + now, + }); + return { candidate, releaseManifest }; +} + +function validateEvidence(value, expectedDigest) { + const evidence = object(value, "scan evidence"); + exact( + evidence, + ["schema", "coverageCount", "passedCount", "checks"], + "scan evidence", + ); + if ( + evidence.schema !== "portfolio-scan-evidence/v1" || + !Number.isSafeInteger(evidence.coverageCount) || + evidence.coverageCount < 1 || + evidence.passedCount !== evidence.coverageCount || + !Array.isArray(evidence.checks) || + evidence.checks.length !== evidence.coverageCount + ) + throw new Error("Scan evidence coverage is incomplete."); + const keys = []; + for (const raw of evidence.checks) { + const check = object(raw, "scan evidence check"); + exact( + check, + [ + "inputDigest", + "checkId", + "normalizedOutputDigest", + "normalizedMembers", + "workerReports", + "matcherResult", + "ruleSetDigest", + "appliedRuleIds", + ], + "scan evidence check", + ); + digest(check.inputDigest, "scan input digest"); + digest(check.normalizedOutputDigest, "normalized output digest"); + digest(check.matcherResult, "matcher result digest"); + digest(check.ruleSetDigest, "rule set digest"); + if ( + !safeToken.test(check.checkId) || + !Array.isArray(check.normalizedMembers) || + check.normalizedMembers.some( + (item) => !/^[a-z][a-z0-9-]*:[a-f0-9]{64}$/u.test(item), + ) || + !Array.isArray(check.workerReports) || + check.workerReports.length !== 2 || + check.workerReports.some((item) => !isSha256Hex(item)) + ) + throw new Error("Scan evidence check is invalid."); + if ( + !Array.isArray(check.appliedRuleIds) || + check.appliedRuleIds.length === 0 || + check.appliedRuleIds.some((item) => !safeToken.test(item)) || + new Set(check.appliedRuleIds).size !== check.appliedRuleIds.length + ) + throw new Error("Applied matcher rule IDs are invalid."); + keys.push(`${check.inputDigest}\0${check.checkId}`); + } + if ( + new Set(keys).size !== keys.length || + JSON.stringify(keys) !== JSON.stringify([...keys].sort(compare)) + ) + throw new Error("Scan evidence checks must be unique and byte sorted."); + if ( + canonicalJsonDigest("portfolio-scan-evidence/v1", evidence) !== + expectedDigest + ) + throw new Error("Scan evidence bytes were substituted."); + return evidence; +} + +function validateProtectedState(state) { + const value = object(state, "protected state"); + exact( + value, + [ + "latestLedgerHeadHash", + "latestLedgerRevision", + "currentManifestDigest", + "publisherFence", + ], + "protected state", + ); + digest(value.latestLedgerHeadHash, "protected ledger head"); + if ( + !Number.isSafeInteger(value.latestLedgerRevision) || + value.latestLedgerRevision < 0 || + !Number.isSafeInteger(value.publisherFence) || + value.publisherFence < 0 || + (value.currentManifestDigest !== null && + !isSha256Hex(value.currentManifestDigest)) + ) + throw new Error("Protected state is invalid."); + return value; +} + +function verifyCandidateAuthority({ + candidate, + evidence, + attestations, + trustPolicy, + now, + protectedState, +}) { + if (trustPolicy.publicationFrozen) + throw new Error("Publication is frozen by trust policy."); + if (typeof now !== "string" || !timestamp.test(now)) + throw new Error("Trusted current time is invalid."); + const { candidate: record, releaseManifest } = validateCandidate( + candidate, + now, + ); + const state = validateProtectedState(protectedState); + if ( + record.latestLedgerHeadHash !== state.latestLedgerHeadHash || + record.latestLedgerRevision !== state.latestLedgerRevision + ) + throw new Error("Candidate is stale relative to the protected ledger."); + if ( + releaseManifest.currentManifestDigest !== state.currentManifestDigest || + releaseManifest.predecessorManifestDigest !== state.currentManifestDigest + ) + throw new Error( + "Candidate predecessor does not equal protected current manifest.", + ); + validateEvidence(evidence, releaseManifest.scanEvidenceDigest); + const candidateDigest = canonicalJsonDigest( + "portfolio-candidate-release-manifest/v1", + record, + ); + const evidenceDigest = canonicalJsonDigest( + "portfolio-scan-evidence/v1", + evidence, + ); + const projection = verifyAttestation( + attestations.projection, + trustPolicy.roots.projection, + "portfolio-approval-projection-attestation/v1", + "projection", + now, + ); + const workload = verifyAttestation( + attestations.privacyWorkload, + trustPolicy.roots.privacyWorkload, + "portfolio-privacy-workload-attestation/v1", + "privacyWorkload", + now, + ); + const policy = verifyAttestation( + attestations.policy, + trustPolicy.roots.policy, + "portfolio-policy-attestation/v1", + "policy", + now, + ); + const tools = verifyAttestation( + attestations.toolImage, + trustPolicy.roots.toolImage, + "portfolio-tool-image-provenance/v1", + "toolImage", + now, + ); + for (const [field, expected] of [ + ["productionInputRoot", releaseManifest.productionInputRoot], + ["approvedRecordRoot", releaseManifest.approvedRecordRoot], + ["approvedAssetRoot", releaseManifest.approvedAssetRoot], + ["approvalProofSetDigest", releaseManifest.approvalProofSetDigest], + ["latestLedgerHeadHash", record.latestLedgerHeadHash], + ["latestLedgerRevision", record.latestLedgerRevision], + ]) { + if (projection[field] !== expected) + throw new Error(`Approval projection mismatch: ${field}.`); + } + if (projection.digest !== releaseManifest.approvalProjectionAttestationDigest) + throw new Error("Projection attestation digest binding is invalid."); + if ( + workload.candidateDigest !== candidateDigest || + workload.evidenceDigest !== evidenceDigest + ) + throw new Error( + "Privacy workload does not bind exact candidate and evidence bytes.", + ); + if ( + policy.digest !== record.privacyPolicyDigest || + policy.policyVersion !== trustPolicy.policyVersion + ) + throw new Error("Privacy policy attestation is stale or substituted."); + if ( + !Array.isArray(tools.imageDigests) || + JSON.stringify(tools.imageDigests) !== + JSON.stringify(record.toolImageDigests) + ) + throw new Error("Tool-image provenance does not bind exact images."); + return { record, releaseManifest, state, candidateDigest, evidenceDigest }; +} + +async function externalPrivateKey(path) { + if (typeof path !== "string" || !isAbsolute(path)) + throw new Error("Release key path must be an external absolute path."); + const workspace = await realpath(process.cwd()); + const lexicalRelative = relative(workspace, resolve(path)); + if ( + lexicalRelative === "" || + (!lexicalRelative.startsWith("..") && !isAbsolute(lexicalRelative)) + ) + throw new Error( + "Release signing key must live outside the repository/workspace.", + ); + const canonicalPath = await realpath(path); + const rel = relative(workspace, canonicalPath); + if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) + throw new Error( + "Release signing key must live outside the repository/workspace.", + ); + const info = await fileStat(canonicalPath); + if (!info.isFile() || (info.mode & 0o077) !== 0) + throw new Error("External release key must be a private owner-only file."); + const key = createPrivateKey(await readFile(canonicalPath)); + if (key.asymmetricKeyType !== "ed25519") + throw new Error("Release key must be Ed25519."); + return key; +} + +export async function signReleaseCandidate(input) { + const trustPolicy = validateTrustPolicy(input.trustPolicy); + const verified = verifyCandidateAuthority({ ...input, trustPolicy }); + const key = await externalPrivateKey(input.releaseKeyPath); + const releaseRoot = trustPolicy.roots.release; + const bytes = canonicalValueBytes(verified.record); + const signatureBytes = sign(null, bytes, key); + if ( + !verify( + null, + bytes, + createPublicKey(releaseRoot.publicKeyPem), + signatureBytes, + ) + ) + throw new Error( + "External key does not match the active release trust root.", + ); + return { + schema: "portfolio-signed-release-manifest/v1", + payload: verified.record, + payloadDigest: verified.candidateDigest, + signature: { + algorithm: "Ed25519", + encoding: "base64url-nopad", + keyId: releaseRoot.keyId, + issuerEpoch: releaseRoot.issuerEpoch, + scope: releaseRoot.scope, + value: signatureBytes.toString("base64url"), + }, + }; +} + +export function verifySignedReleaseEnvelope({ + signedRelease, + trustPolicy: trustValue, + now, + protectedState, +}) { + const trustPolicy = validateTrustPolicy(trustValue); + const envelope = object(signedRelease, "signed release"); + exact(envelope, signedFields, "signed release"); + if (envelope.schema !== "portfolio-signed-release-manifest/v1") + throw new Error("Signed release schema is invalid."); + const signature = object(envelope.signature, "release signature"); + exact(signature, signatureFields, "release signature"); + const root = trustPolicy.roots.release; + if ( + signature.algorithm !== "Ed25519" || + signature.encoding !== "base64url-nopad" || + signature.keyId !== root.keyId || + signature.issuerEpoch !== root.issuerEpoch || + signature.scope !== root.scope || + typeof signature.value !== "string" + ) + throw new Error( + "Release signature key, epoch, scope, or encoding is invalid.", + ); + const { candidate, releaseManifest } = validateCandidate( + envelope.payload, + now, + ); + const payloadDigest = canonicalJsonDigest( + "portfolio-candidate-release-manifest/v1", + candidate, + ); + if (envelope.payloadDigest !== payloadDigest) + throw new Error("Signed release payload digest is invalid."); + const bytes = Buffer.from(signature.value, "base64url"); + if ( + bytes.toString("base64url") !== signature.value || + !verify( + null, + canonicalValueBytes(candidate), + createPublicKey(root.publicKeyPem), + bytes, + ) + ) + throw new Error("Release signature is invalid."); + const state = validateProtectedState(protectedState); + if ( + candidate.latestLedgerHeadHash !== state.latestLedgerHeadHash || + candidate.latestLedgerRevision !== state.latestLedgerRevision || + releaseManifest.currentManifestDigest !== state.currentManifestDigest || + releaseManifest.predecessorManifestDigest !== state.currentManifestDigest + ) + throw new Error("Signed release is stale relative to protected state."); + return { + schema: "portfolio-verified-release-admission/v1", + manifestDigest: payloadDigest, + manifestPayload: releaseManifest, + expiresAt: releaseManifest.expiresAt, + predecessorManifestDigest: releaseManifest.predecessorManifestDigest, + latestLedgerHeadHash: candidate.latestLedgerHeadHash, + latestLedgerRevision: candidate.latestLedgerRevision, + expectedFence: state.publisherFence + 1, + signatureVerified: true, + }; +} + +async function main() { + const args = process.argv.slice(2); + if (args.length !== 4 || args[0] !== "--request" || args[2] !== "--key") + throw new Error( + "Usage: sign-release.mjs --request --key ", + ); + const request = parseCanonicalJson(await readFile(resolve(args[1]))); + const signed = await signReleaseCandidate({ + ...object(request, "sign request"), + releaseKeyPath: resolve(args[3]), + }); + process.stdout.write(Buffer.from(canonicalValueBytes(signed))); + process.stdout.write("\n"); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main().catch((error) => { + process.stderr.write( + `Release signing failed: ${error instanceof Error ? error.message : "unknown error"}\n`, + ); + process.exitCode = 1; + }); +} diff --git a/scripts/release/trust-policy.schema.json b/scripts/release/trust-policy.schema.json new file mode 100644 index 0000000..8e06fce --- /dev/null +++ b/scripts/release/trust-policy.schema.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://portfolio.invalid/schemas/trust-policy-v1.json", + "title": "Portfolio release trust policy", + "type": "object", + "additionalProperties": false, + "required": ["schema", "policyVersion", "publicationFrozen", "roots"], + "properties": { + "schema": { "const": "portfolio-trust-policy/v1" }, + "policyVersion": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$" + }, + "publicationFrozen": { "type": "boolean" }, + "roots": { + "type": "object", + "additionalProperties": false, + "required": [ + "approval", + "projection", + "privacyWorkload", + "policy", + "toolImage", + "release", + "publisher" + ], + "properties": { + "approval": { "$ref": "#/$defs/root" }, + "projection": { "$ref": "#/$defs/root" }, + "privacyWorkload": { "$ref": "#/$defs/root" }, + "policy": { "$ref": "#/$defs/root" }, + "toolImage": { "$ref": "#/$defs/root" }, + "release": { "$ref": "#/$defs/root" }, + "publisher": { "$ref": "#/$defs/root" } + } + } + }, + "$defs": { + "root": { + "type": "object", + "additionalProperties": false, + "required": [ + "keyId", + "issuerEpoch", + "scope", + "publicKeyPem", + "revokedEpochs", + "compromiseResponse" + ], + "properties": { + "keyId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$" + }, + "issuerEpoch": { "type": "integer", "minimum": 1 }, + "scope": { "type": "string" }, + "publicKeyPem": { "type": "string" }, + "revokedEpochs": { + "type": "array", + "uniqueItems": true, + "items": { "type": "integer", "minimum": 1 } + }, + "compromiseResponse": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string" } + } + } + } + } +} diff --git a/tests/security/privacy-workers.test.ts b/tests/security/privacy-workers.test.ts new file mode 100644 index 0000000..9b7e13d --- /dev/null +++ b/tests/security/privacy-workers.test.ts @@ -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 = {}, +) { + 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 = {}) { + 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, + ); + }); +}); diff --git a/tests/security/release-signing.test.ts b/tests/security/release-signing.test.ts new file mode 100644 index 0000000..4769407 --- /dev/null +++ b/tests/security/release-signing.test.ts @@ -0,0 +1,441 @@ +import { generateKeyPairSync, sign, verify } from "node:crypto"; +import { mkdtemp, 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 { computeProductionInputRoot } from "../../src/security/production-input-root.ts"; +// @ts-expect-error executable release modules intentionally have no browser declarations +import * as releaseSigner from "../../scripts/release/sign-release.mjs"; + +const { + signReleaseCandidate, + validateTrustPolicy, + verifySignedReleaseEnvelope, +} = releaseSigner; + +const h = (c: string) => c.repeat(64); +const roles = [ + ["approval", "portfolio-approval-decision/v1"], + ["projection", "portfolio-approval-projection/v1"], + ["privacyWorkload", "portfolio-privacy-workload/v1"], + ["policy", "portfolio-policy-attestation/v1"], + ["toolImage", "portfolio-tool-image-provenance/v1"], + ["release", "portfolio-release-signing/v1"], + ["publisher", "portfolio-publisher-fencing/v1"], +] as const; + +function keys(): Record { + return Object.fromEntries( + roles.map(([role]) => [role, generateKeyPairSync("ed25519")]), + ) as Record; +} + +function policy(keyPairs: ReturnType): any { + return { + schema: "portfolio-trust-policy/v1", + policyVersion: "2026-09-02", + publicationFrozen: false, + roots: Object.fromEntries( + roles.map(([role, scope]) => [ + role, + { + keyId: `${role}-key`, + issuerEpoch: 2, + scope, + publicKeyPem: keyPairs[role]!.publicKey.export({ + type: "spki", + format: "pem", + }), + revokedEpochs: [1], + compromiseResponse: + role === "approval" + ? ["freeze-publication", "audit-checkpoint", "fresh-approvals"] + : role === "publisher" + ? [ + "freeze-publication", + "rotate-credential", + "advance-fence", + "reconcile-staging", + ] + : ["freeze-publication", "revoke-epoch", "fresh-evidence"], + }, + ]), + ), + }; +} + +function envelope( + role: string, + scope: string, + keyPairs: ReturnType, + payload: Record = {}, +) { + const schema = { + projection: "portfolio-approval-projection-attestation/v1", + privacyWorkload: "portfolio-privacy-workload-attestation/v1", + policy: "portfolio-policy-attestation/v1", + toolImage: "portfolio-tool-image-provenance/v1", + }[role]; + const body = { + schema, + keyId: `${role}-key`, + issuerEpoch: 2, + scope, + issuedAt: "2026-09-02T00:00:00Z", + expiresAt: "2026-09-03T00:00:00Z", + ...payload, + }; + return { + payload: body, + signature: { + algorithm: "Ed25519", + encoding: "base64url-nopad", + value: sign( + null, + canonicalValueBytes(body), + keyPairs[role]!.privateKey, + ).toString("base64url"), + }, + }; +} + +function fixture() { + const keyPairs = keys(); + const inputDigests = { + PortfolioContent: h("a"), + PublicAssetManifest: h("b"), + PreparedAssetSet: h("c"), + SiteConfig: h("d"), + }; + const releaseManifest = { + schema: "portfolio-release-manifest/v1", + launchMode: "private-source-initial-launch", + buildId: "r1", + inputDigests, + productionInputRoot: computeProductionInputRoot({ + portfolioContentDigest: h("a"), + publicAssetManifestDigest: h("b"), + preparedAssetSetDigest: h("c"), + siteConfigDigest: h("d"), + }), + artifacts: [ + { path: "index.html", sha256: h("6"), bytes: 42, encoding: "identity" }, + ], + deployFiles: ["index.html"], + sourceCommit: h("7"), + sourceTree: h("8"), + approvedRecordRoot: h("9"), + approvedAssetRoot: h("e"), + approvalProofSetDigest: h("f"), + approvalProjectionAttestationDigest: h("2"), + scanEvidenceDigest: h("3"), + policyVersions: { approval: "1", privacy: "1", release: "1" }, + toolVersions: { node: "22" }, + predecessorManifestDigest: null, + currentManifestDigest: null, + issuedAt: "2026-09-02T00:00:00Z", + expiresAt: "2026-09-02T12:00:00Z", + minimumBoundExpiry: "2026-09-02T12:00:00Z", + }; + const candidate = { + schema: "portfolio-candidate-release-manifest/v1", + releaseManifest, + latestLedgerHeadHash: h("1"), + latestLedgerRevision: 7, + sourceClosureDigest: h("4"), + publicRefMap: {}, + lfsOids: [], + releaseArchives: [], + buildProvenanceDigest: h("5"), + privacyPolicyDigest: h("a"), + toolImageDigests: [h("b"), h("c")], + }; + 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"], + }, + ], + }; + const evidenceDigest = canonicalJsonDigest( + "portfolio-scan-evidence/v1", + evidence, + ); + candidate.releaseManifest.scanEvidenceDigest = evidenceDigest; + 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, + }), + privacyWorkload: envelope("privacyWorkload", roles[2][1], keyPairs, { + digest: h("2"), + candidateDigest, + evidenceDigest, + }), + policy: envelope("policy", roles[3][1], keyPairs, { + digest: h("a"), + policyVersion: "2026-09-02", + }), + toolImage: envelope("toolImage", roles[4][1], keyPairs, { + digest: h("b"), + imageDigests: [h("b"), h("c")], + }), + }; + return { + keyPairs, + candidate, + evidence, + candidateDigest, + evidenceDigest, + attestations, + trustPolicy: policy(keyPairs), + }; +} + +describe("external scoped release signing", () => { + test("signs exact canonical candidate bytes with an external Ed25519 key", 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 }, + ); + const result = await signReleaseCandidate({ + ...f, + releaseKeyPath: keyPath, + now: "2026-09-02T01:00:00Z", + protectedState: { + latestLedgerHeadHash: h("1"), + latestLedgerRevision: 7, + currentManifestDigest: null, + publisherFence: 4, + }, + }); + expect(result.payloadDigest).toBe(f.candidateDigest); + expect( + verify( + null, + canonicalValueBytes(f.candidate), + f.keyPairs.release!.publicKey, + Buffer.from(result.signature.value, "base64url"), + ), + ).toBe(true); + expect( + verifySignedReleaseEnvelope({ + signedRelease: result, + trustPolicy: f.trustPolicy, + now: "2026-09-02T01:00:00Z", + protectedState: { + latestLedgerHeadHash: h("1"), + latestLedgerRevision: 7, + currentManifestDigest: null, + publisherFence: 4, + }, + }), + ).toMatchObject({ + schema: "portfolio-verified-release-admission/v1", + manifestDigest: f.candidateDigest, + expiresAt: "2026-09-02T12:00:00Z", + predecessorManifestDigest: null, + latestLedgerHeadHash: h("1"), + latestLedgerRevision: 7, + expectedFence: 5, + signatureVerified: true, + }); + }); + + test("verified admission rejects stale publisher state and substituted signed payloads", 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 }, + ); + const state = { + latestLedgerHeadHash: h("1"), + latestLedgerRevision: 7, + currentManifestDigest: null, + publisherFence: 4, + }; + const signedRelease = await signReleaseCandidate({ + ...f, + releaseKeyPath: keyPath, + now: "2026-09-02T01:00:00Z", + protectedState: state, + }); + expect(() => + verifySignedReleaseEnvelope({ + signedRelease, + trustPolicy: f.trustPolicy, + now: "2026-09-02T01:00:00Z", + protectedState: { ...state, latestLedgerRevision: 8 }, + }), + ).toThrow(/stale/i); + expect(() => + verifySignedReleaseEnvelope({ + signedRelease: { + ...signedRelease, + payload: { ...signedRelease.payload, latestLedgerRevision: 8 }, + }, + trustPolicy: f.trustPolicy, + now: "2026-09-02T01:00:00Z", + protectedState: state, + }), + ).toThrow(); + }); + + test("refuses repository-resident keys and non-external key material", async () => { + const f = fixture(); + await expect( + signReleaseCandidate({ + ...f, + releaseKeyPath: join(process.cwd(), "release.pem"), + now: "2026-09-02T01:00:00Z", + protectedState: { + latestLedgerHeadHash: h("1"), + latestLedgerRevision: 7, + currentManifestDigest: null, + publisherFence: 4, + }, + }), + ).rejects.toThrow(/outside|external/i); + }); + + test.each(roles)( + "requires current non-revoked %s issuer epoch and exact scope", + (role) => { + const f = fixture(); + const mutated = structuredClone(f.trustPolicy); + mutated.roots[role].issuerEpoch = 1; + expect(() => validateTrustPolicy(mutated)).toThrow(/revoked|epoch/i); + const wrongScope = structuredClone(f.trustPolicy); + wrongScope.roots[role].scope = "wrong/v1"; + expect(() => validateTrustPolicy(wrongScope)).toThrow(/scope/i); + }, + ); + + test("requires explicit compromise recovery semantics for approval and publisher roots", () => { + const f = fixture(); + const approval = structuredClone(f.trustPolicy); + approval.roots.approval.compromiseResponse = ["freeze-publication"]; + expect(() => validateTrustPolicy(approval)).toThrow( + /checkpoint|fresh approvals/i, + ); + const publisher = structuredClone(f.trustPolicy); + publisher.roots.publisher.compromiseResponse = [ + "freeze-publication", + "rotate-credential", + ]; + expect(() => validateTrustPolicy(publisher)).toThrow(/fence|staging/i); + }); + + test.each([ + [ + "stale ledger", + (f: ReturnType) => ({ + protectedState: { + latestLedgerHeadHash: h("9"), + latestLedgerRevision: 7, + currentManifestDigest: null, + publisherFence: 4, + }, + }), + ], + [ + "expired candidate", + (f: ReturnType) => ({ now: "2026-09-02T12:00:00Z" }), + ], + [ + "substituted artifact", + (f: ReturnType) => ({ + candidate: { + ...f.candidate, + releaseManifest: { + ...f.candidate.releaseManifest, + artifacts: [ + { ...f.candidate.releaseManifest.artifacts[0], bytes: 43 }, + ], + }, + }, + }), + ], + [ + "forged workload epoch", + (f: ReturnType) => ({ + attestations: { + ...f.attestations, + privacyWorkload: { + ...f.attestations.privacyWorkload, + payload: { + ...f.attestations.privacyWorkload.payload, + issuerEpoch: 1, + }, + }, + }, + }), + ], + [ + "projection mismatch", + (f: ReturnType) => ({ + candidate: { + ...f.candidate, + releaseManifest: { + ...f.candidate.releaseManifest, + productionInputRoot: h("9"), + }, + }, + }), + ], + ])("rejects %s before signing", async (_name, mutate) => { + 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: "2026-09-02T01:00:00Z", + protectedState: { + latestLedgerHeadHash: h("1"), + latestLedgerRevision: 7, + currentManifestDigest: null, + publisherFence: 4, + }, + ...mutate(f), + }), + ).rejects.toThrow(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index aafa37e..d4150cb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,7 +10,12 @@ export default defineConfig({ test: { environment: 'happy-dom', globals: false, - include: ['tests/unit/**/*.test.ts', 'tests/integration/**/*.test.{ts,mjs}', 'tests/performance/**/*.test.ts'], + include: [ + 'tests/unit/**/*.test.ts', + 'tests/integration/**/*.test.{ts,mjs}', + 'tests/performance/**/*.test.ts', + 'tests/security/**/*.test.ts', + ], coverage: { include: ['src/**/*.{ts,vue}'] }, }, });