148 lines
5.2 KiB
JavaScript
148 lines
5.2 KiB
JavaScript
|
|
#!/usr/bin/env node
|
||
|
|
/**
|
||
|
|
* Attestation validator.
|
||
|
|
*
|
||
|
|
* A vendor may NOT claim a level. This computes the level from which artifacts
|
||
|
|
* actually exist, and downgrades self-run observation to a declaration.
|
||
|
|
*
|
||
|
|
* The core rule:
|
||
|
|
* A vendor running our harness produces a DECLARATION, not an OBSERVATION.
|
||
|
|
* Declarations cap at level 1. Levels 3 and 4 survive a self-run because a
|
||
|
|
* public rebuild recipe and a published adversarial review can be re-checked
|
||
|
|
* by anyone who wants to.
|
||
|
|
*
|
||
|
|
* What this can and cannot do, stated plainly:
|
||
|
|
* CAN bound a specific binary, enumerate what it talks to, and prove that a
|
||
|
|
* rebuild reproduces it bit for bit.
|
||
|
|
* CANNOT prove the absence of a backdoor. Rice's theorem makes that undecidable
|
||
|
|
* for arbitrary code, and a backdoor delivered by remote configuration or
|
||
|
|
* triggered by message content is not visible to a static scan.
|
||
|
|
* So the honest claim is: an attestation RAISES THE COST of a backdoor and
|
||
|
|
* SHORTENS ITS SHELF LIFE. It is not a proof of purity.
|
||
|
|
*
|
||
|
|
* Usage:
|
||
|
|
* node validate.mjs <attestation.json> compute the level for one file
|
||
|
|
* node validate.mjs --example print a filled example
|
||
|
|
*/
|
||
|
|
import { readFileSync } from 'node:fs';
|
||
|
|
|
||
|
|
/** Highest level whose required artifacts are all present. */
|
||
|
|
export function artifactsLevel(a) {
|
||
|
|
let level = 0;
|
||
|
|
const has = (v) => v !== null && v !== undefined && v !== '';
|
||
|
|
|
||
|
|
if (has(a.sbom_url) && has(a.signed_hash_url) && has(a.destination_allowlist_url)) {
|
||
|
|
level = 1;
|
||
|
|
}
|
||
|
|
if (level >= 1 && has(a.capture_report)) {
|
||
|
|
level = 2;
|
||
|
|
}
|
||
|
|
if (level >= 2 && has(a.rebuild_recipe_url) && a.rebuild_matches === 1) {
|
||
|
|
level = 3;
|
||
|
|
}
|
||
|
|
if (level >= 3 && has(a.adversarial_report_url) && a.differential_clean === 1) {
|
||
|
|
level = 4;
|
||
|
|
}
|
||
|
|
return level;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Apply the trust rule: who ran it decides what the result is worth. */
|
||
|
|
export function effectiveLevel(a) {
|
||
|
|
let level = artifactsLevel(a);
|
||
|
|
|
||
|
|
// A self-observed capture is a claim about oneself. Downgrade to declaration.
|
||
|
|
if (a.attestor === 'vendor' && level === 2) level = 1;
|
||
|
|
|
||
|
|
// Levels 3 and 4 are not weakened by self-running: the recipe and the review
|
||
|
|
// are public artifacts that a third party can re-execute or re-read.
|
||
|
|
return level;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Maximum score a dimension may reach at a given effective level. */
|
||
|
|
export const DIMENSION_CAP = {
|
||
|
|
'verifiability': { 0: 3, 1: 4, 2: 5, 3: 9, 4: 10 },
|
||
|
|
'telemetry_egress': { 0: 3, 1: 5, 2: 7, 3: 9, 4: 10 },
|
||
|
|
'default': { 0: 3, 1: 5, 2: 7, 3: 9, 4: 10 },
|
||
|
|
};
|
||
|
|
|
||
|
|
export function capFor(dimensionId, level) {
|
||
|
|
const table = DIMENSION_CAP[dimensionId] ?? DIMENSION_CAP.default;
|
||
|
|
return table[level] ?? 3;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Full validation report for one attestation. */
|
||
|
|
export function validate(a, { now = new Date() } = {}) {
|
||
|
|
const problems = [];
|
||
|
|
const level = effectiveLevel(a);
|
||
|
|
const raw = artifactsLevel(a);
|
||
|
|
|
||
|
|
if (raw > level) {
|
||
|
|
problems.push(
|
||
|
|
`claim of level ${raw} reduced to ${level}: attestor is '${a.attestor}', ` +
|
||
|
|
`and a self-run observation is a declaration, not an observation`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (!a.artifact_sha256 || !/^[a-f0-9]{64}$/.test(a.artifact_sha256)) {
|
||
|
|
problems.push('artifact_sha256 missing or not a 64-char lowercase hex digest');
|
||
|
|
}
|
||
|
|
if (!a.version) problems.push('no version: an attestation must name the release it covers');
|
||
|
|
if (a.expires_on && new Date(a.expires_on) <= now) {
|
||
|
|
problems.push(`expired on ${a.expires_on}: every new build invalidates the certificate`);
|
||
|
|
}
|
||
|
|
if (level >= 3 && a.rebuild_matches !== 1) {
|
||
|
|
problems.push('level 3 requires rebuild_matches = 1');
|
||
|
|
}
|
||
|
|
if (level >= 4 && !a.adversarial_report_url) {
|
||
|
|
problems.push('level 4 requires a published adversarial review');
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
level,
|
||
|
|
claimed_artifacts_level: raw,
|
||
|
|
valid: problems.length === 0,
|
||
|
|
problems,
|
||
|
|
caps: {
|
||
|
|
verifiability: capFor('verifiability', level),
|
||
|
|
other_dimensions: capFor('default', level),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------------------ cli
|
||
|
|
if (process.argv[1] && process.argv[1].endsWith('validate.mjs')) {
|
||
|
|
const arg = process.argv[2];
|
||
|
|
|
||
|
|
if (!arg || arg === '--example') {
|
||
|
|
console.log(JSON.stringify({
|
||
|
|
product_id: 'slack',
|
||
|
|
version: '4.52.182',
|
||
|
|
artifact_sha256: '0'.repeat(64),
|
||
|
|
sbom_url: 'https://example.com/slack-4.52.182.cdx.json',
|
||
|
|
signed_hash_url: 'https://example.com/slack-4.52.182.sig',
|
||
|
|
destination_allowlist_url: 'https://example.com/slack-destinations.txt',
|
||
|
|
capture_report: null,
|
||
|
|
rebuild_recipe_url: null,
|
||
|
|
rebuild_matches: null,
|
||
|
|
adversarial_report_url: null,
|
||
|
|
differential_clean: null,
|
||
|
|
issued_on: '2026-10-29',
|
||
|
|
expires_on: '2026-11-29',
|
||
|
|
auditor: 'CLU',
|
||
|
|
attestor: 'vendor',
|
||
|
|
}, null, 2));
|
||
|
|
process.exit(0);
|
||
|
|
}
|
||
|
|
|
||
|
|
const a = JSON.parse(readFileSync(arg, 'utf8'));
|
||
|
|
const r = validate(a);
|
||
|
|
console.log(`effective level : ${r.level}`);
|
||
|
|
console.log(`artifacts level : ${r.claimed_artifacts_level}`);
|
||
|
|
console.log(`valid : ${r.valid}`);
|
||
|
|
console.log(`caps : verifiability <= ${r.caps.verifiability}, others <= ${r.caps.other_dimensions}`);
|
||
|
|
if (r.problems.length) {
|
||
|
|
console.log('problems:');
|
||
|
|
for (const p of r.problems) console.log(` - ${p}`);
|
||
|
|
}
|
||
|
|
process.exit(r.valid ? 0 : 1);
|
||
|
|
}
|