feat: isolate privacy scanning and release signing
This commit is contained in:
282
scripts/release/reconcile-scan.mjs
Normal file
282
scripts/release/reconcile-scan.mjs
Normal file
@@ -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 <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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user