Files
Portfolio/scripts/release/reconcile-scan.mjs

266 lines
7.8 KiB
JavaScript

#!/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;
}
}
function closureDigest(value, schema, label) {
const closure = object(value, label);
exact(closure, ["schema", "members"], label);
if (closure.schema !== schema || !Array.isArray(closure.members))
throw new Error(`${label} is invalid.`);
return canonicalJsonDigest(schema, closure);
}
export function reconcileScan(input) {
const request = object(input, "reconciliation request");
exact(
request,
[
"sourceClosure",
"publicObjectClosure",
"reports",
"matcherResults",
"candidate",
],
"reconciliation request",
);
const {
sourceClosure,
publicObjectClosure,
reports,
matcherResults,
candidate,
} = request;
if (!Array.isArray(reports) || !Array.isArray(matcherResults))
throw new Error("Reconciliation input is invalid.");
const sourceClosureDigest = closureDigest(
sourceClosure,
"portfolio-publication-closure/v1",
"source closure",
);
const publicObjectClosureDigest = closureDigest(
publicObjectClosure,
"portfolio-public-object-closure/v1",
"public object closure",
);
if (
candidate?.sourceClosureDigest !== sourceClosureDigest ||
candidate?.publicObjectClosureDigest !== publicObjectClosureDigest
) {
throw new Error(
"Candidate does not bind the exact canonical source/public closure.",
);
}
if (publicObjectClosure.members.length !== 0) {
throw new Error(
"Verified privacy scanners are unavailable; every non-empty public object closure fails closed.",
);
}
if (reports.length !== 0 || matcherResults.length !== 0)
throw new Error(
"Empty public object closure cannot carry fabricated scan evidence.",
);
const checks = [];
const evidence = {
schema: "portfolio-scan-evidence/v1",
sourceClosureDigest,
publicObjectClosureDigest,
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 <canonical-request.json>",
);
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;
});
}