aere-proof-of-software/tools/proof-of-software/pos.mjs

841 lines
68 KiB
JavaScript

#!/usr/bin/env node
// Aere Proof of Software: an attestation of WHAT was built, FROM WHAT, and WHEN, that anyone can verify without us.
//
// node pos.mjs attest --out attestation.json [--name N --version V] [--sbom sbom.json] [--keys keys.json]
// [--cloud-key-file FILE] <artifact files...>
// node pos.mjs keygen --out keys.json [--alg secp256k1+ml-dsa-65]
// node pos.mjs verify attestation.json [--cloud-key-file FILE] [--rebuild-from CLONE] [--signer pub.json] <artifact files...>
// node pos.mjs pack-npm --source-path DIR [--commit C] [--out-dir D] (npm pack of the COMMITTED tree, prints the file)
// node pos.mjs sbom-go --source-path DIR --version V [--commit C] [--out sbom.cdx.json] (1.4.0: SBOM of a Go module from the
// COMMITTED go.mod and go.sum, deterministic; verify --rebuild-from re-derives and compares it)
// node pos.mjs sbom-nuget --source-path DIR --version V [--commit C] [--out sbom.cdx.json] (1.5.0: .NET, from the COMMITTED
// packages.lock.json and the project file next to it; deterministic, re-derived by verify --rebuild-from)
// node pos.mjs sbom-gradle --source-path DIR --version V [--commit C] [--out sbom.cdx.json] (1.5.0: Gradle, from the COMMITTED
// gradle/verification-metadata.xml and gradle.lockfile; deterministic, re-derived by verify --rebuild-from)
// node pos.mjs model-bom --model-dir DIR --out mlbom.json [--name N --version V] [--task T] [--dataset NAME=FILE ...]
// node pos.mjs pubkey --keys keys.json [--out pub.json] (the public part, to hand out)
// node pos.mjs credential issue --issuer-keys org.json --issuer-name O --subject-pub dev.pub.json --subject-name D [--valid-days 365]
// node pos.mjs credential revoke --issuer-keys org.json --credential cred.json [--at ISO] [--reason R]
// attest ... --credential cred.json verify ... --trust-issuer org.pub.json [--revocations r1.json,r2.json] (builder identity, 1.3.0)
//
// AI provenance (1.2.0): model-bom writes a CycloneDX 1.6 ML-BOM for a model directory (a machine-learning-model component,
// every file of the directory with its SHA-256, datasets you name with theirs, the architecture read from config.json).
// Attest it with the files: attest --base DIR --sbom mlbom.json <every file of DIR> and verify with the same --base.
// --base names artifacts by their path relative to DIR (a model often has two config.json in two folders); files larger
// than 64 MiB are hashed in a stream, so multi-gigabyte weights never have to fit in memory.
//
// attest --source-path DIR --build npm-pack also records the git TREE of DIR and the build; `verify --rebuild-from`
// then repeats the build from a clone the verifier chose and requires the attested digest, byte for byte.
//
// The statement lists every artifact with its SHA-256 and size, the SBOM's digest (CycloneDX/SPDX, any format: it is
// hashed, not interpreted), the git commit and remote of the source tree, the builder and the time. Its canonical
// text is JSON.stringify(statement) as written; statementHash = sha256 of that text.
// --keys signs the statement text with a HYBRID signature (@aere/pq-sign: classical + ML-DSA, both required)
// --cloud-key notarizes statementHash on chain 2800 through Aere Cloud (POST /v1/notarize); the proof of it, with
// the covering post-quantum anchor, is then GET /v1/proof/{statementHash}
// `verify` recomputes every digest from the files it is given, checks the statement text against statementHash,
// verifies the hybrid signature, and (with a Cloud key) asks the chain for the proof and its finality. It exits 0 only
// when everything that is present holds; what is absent is reported as absent, never as valid.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import * as pq from '../../sdk-pq-sign/index.mjs';
import { AereCloud } from '../../sdk/index.mjs';
export const TOOL = 'aere-proof-of-software/1.5.0';
const AICI = path.dirname(fileURLToPath(import.meta.url));
// peste prag, in flux: o greutate de model de cativa GB nu are voie sa fie citita intreaga in memorie (2026-09-25)
const PRAG_FLUX = Number(process.env.AERE_POS_FLUX_PESTE || 64 * 1024 * 1024);
export function sha256File(p) {
const st = fs.statSync(p);
if (st.size <= PRAG_FLUX) return '0x' + crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
const h = crypto.createHash('sha256'), buf = Buffer.allocUnsafe(8 * 1024 * 1024), fd = fs.openSync(p, 'r');
let citit = 0;
try {
for (let n; (n = fs.readSync(fd, buf, 0, buf.length, citit)) > 0; citit += n) h.update(n === buf.length ? buf : buf.subarray(0, n));
} finally { fs.closeSync(fd); }
if (citit !== st.size) throw new Error(`${p}: read ${citit} bytes of ${st.size} (the file changed while it was hashed)`);
return '0x' + h.digest('hex');
}
const sha256Text = (t) => '0x' + crypto.createHash('sha256').update(t, 'utf8').digest('hex');
const git = (dir, args) => { try { return execFileSync('git', args, { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1 << 28 }).toString().trim(); } catch { return null; } };
// With sourcePath the statement also names the git TREE of that directory at HEAD. A tree hash is a digest of content:
// two checkouts with the same tree hold the same committed bytes, whatever the line endings of their working copies.
function gitInfo(dir, sourcePath) {
const commit = git(dir, ['rev-parse', 'HEAD']);
if (!commit) { if (sourcePath) throw new Error('--source-path needs a git checkout'); return null; }
const remote = git(dir, ['remote', 'get-url', 'origin']);
const dirty = git(dir, ['status', '--porcelain']);
const info = {
commit,
remote: remote ? remote.replace(/\/\/[^@/]+@/, '//') : null, // no credentials in the statement
dirty: dirty === null ? null : dirty.length > 0,
};
if (sourcePath) {
const top = git(dir, ['rev-parse', '--show-toplevel']);
const rel = path.relative(path.resolve(top), path.resolve(dir, sourcePath)).split(path.sep).join('/');
if (!rel || rel.startsWith('..')) throw new Error(`--source-path ${sourcePath}: must be a directory below the repository root`);
const tree = git(dir, ['rev-parse', '--verify', '--quiet', `HEAD:${rel}`]);
if (!tree) throw new Error(`--source-path ${sourcePath}: "${rel}" is not tracked at HEAD`);
const pd = git(dir, ['status', '--porcelain', '--', path.resolve(dir, sourcePath)]);
info.path = rel; info.tree = tree; info.pathDirty = pd === null ? null : pd.length > 0;
}
return info;
}
// The committed bytes of <treeish>, written as they are in the object store: no checkout conversion (autocrlf, eol),
// so the same tree gives the same files on every machine. Submodule entries are skipped. --full-tree: without it ls-tree filters the
// tree by the caller's directory inside the repository, and from a subdirectory the listing comes back EMPTY.
export function exportTree(repoDir, treeish, dest) {
const out = execFileSync('git', ['ls-tree', '-r', '-z', '--full-tree', treeish], { cwd: repoDir, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1 << 28 }).toString();
let n = 0;
for (const rec of out.split('\0').filter(Boolean)) {
const tab = rec.indexOf('\t'); const [mode, type, sha] = rec.slice(0, tab).split(' '); const name = rec.slice(tab + 1);
if (type !== 'blob') continue;
const p = path.join(dest, name); fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, execFileSync('git', ['cat-file', 'blob', sha], { cwd: repoDir, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1 << 28 }));
if (mode === '100755') { try { fs.chmodSync(p, 0o755); } catch { /* no mode bits on this filesystem */ } }
n++;
}
if (!n) throw new Error(`${treeish}: no files`);
return n;
}
const npmVersion = () => { try { return execFileSync('npm --version', { shell: true, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch { return null; } };
// `npm pack` of the COMMITTED tree (never of the working copy): the artifact is then a function of the tree alone.
export function packNpmFromTree(repoDir, treeish, outDir) {
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-src-'));
try {
exportTree(repoDir, treeish, src);
fs.mkdirSync(outDir, { recursive: true });
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-out-'));
const rel = path.relative(src, stage); // no spaces of ours in it, and quoted anyway
const name = execFileSync(`npm pack --silent --ignore-scripts --pack-destination "${rel}"`, { cwd: src, shell: true, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim().split(/\r?\n/).pop();
const out = path.join(outDir, name); fs.copyFileSync(path.join(stage, name), out); fs.rmSync(stage, { recursive: true, force: true });
return out;
} finally { fs.rmSync(src, { recursive: true, force: true }); }
}
// numele unui artefact: bazenumele, sau calea relativa la --base (cu / ca separator, oricare ar fi sistemul)
export function numeArtefact(p, base) {
if (!base) return path.basename(p);
const rel = path.relative(path.resolve(base), path.resolve(p));
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) throw new Error(`${p} is not inside --base ${base}`);
return rel.split(path.sep).join('/');
}
// ---------------------------------------------------------------- builder identity: the developer credential (1.3.0)
// A signature says "these keys signed"; it does not say WHO holds them. A developer credential is an organization's statement,
// signed with its own hybrid keys, that names a person and binds that name to the person's signing keys for a period:
// { v:1, kind:'aere-developer-credential', body:{ issuer:{name, keys}, subject:{name, keys}, validFrom, validUntil }, signature }
// `attest --credential` puts sha256(body text) into the statement (builder.credential), so the signature over the statement also
// covers "I built this under credential C". `verify --trust-issuer` then requires: the issuer is the trust root the VERIFIER chose
// (never the one the attestation carries), the credential names exactly the keys that signed the statement, the time falls in its
// validity, and no revocation signed by the issuer covers that time. The time is the chain's first-seen time when the proof was
// read, otherwise the builder's own createdAt, and the check says which one it used.
export function publicKeysOf(k) {
if (!k) return null;
if (k.classicalPublicKey && k.pqPublicKey) return { alg: k.alg, classicalPublicKey: k.classicalPublicKey, pqPublicKey: k.pqPublicKey };
if (k.classical && k.pq) return { alg: k.alg, classicalPublicKey: k.classical.publicKey, pqPublicKey: k.pq.publicKey };
return null;
}
const keyId = (pub) => (pub ? sha256Text(JSON.stringify({ alg: pub.alg, classicalPublicKey: pub.classicalPublicKey, pqPublicKey: pub.pqPublicKey })) : null);
export const credentialHash = (cred) => sha256Text(JSON.stringify(cred.body));
export function issueCredential({ issuerKeys, issuerName, subjectKeys, subjectName, validFrom, validUntil }) {
const sub = publicKeysOf(subjectKeys);
if (!sub) throw new Error('the subject public keys are required (keys.json or its public part)');
if (!(Date.parse(validUntil) > Date.parse(validFrom))) throw new Error('validUntil must be after validFrom');
const body = { issuer: { name: String(issuerName), keys: publicKeysOf(issuerKeys) }, subject: { name: String(subjectName), keys: sub },
validFrom: new Date(validFrom).toISOString(), validUntil: new Date(validUntil).toISOString() };
return { v: 1, kind: 'aere-developer-credential', body, signature: pq.sign(JSON.stringify(body), issuerKeys) };
}
export function revokeCredential({ issuerKeys, credential, revokedAt, reason = '' }) {
const body = { credential: credentialHash(credential), revokedAt: new Date(revokedAt).toISOString(), reason: String(reason).slice(0, 200), issuer: publicKeysOf(issuerKeys) };
return { v: 1, kind: 'aere-developer-credential-revocation', body, signature: pq.sign(JSON.stringify(body), issuerKeys) };
}
function credentialChecks(att, { trustIssuer, revocations, chainTime }, ok, checks) {
const want = att.statement.builder && att.statement.builder.credential;
if (!want) { checks.push({ name: 'builder identity', pass: null, detail: 'no developer credential in the statement' }); return; }
const cred = att.credential;
if (!ok('credential: the attestation carries the credential the statement names', !!(cred && cred.kind === 'aere-developer-credential' && cred.body && credentialHash(cred) === want), cred ? 'hash differs' : 'absent')) return;
const b = cred.body;
const sv = pq.verify(JSON.stringify(b), cred.signature || {});
const semnatarEmitent = keyId(publicKeysOf(cred.signature)) === keyId(b.issuer && b.issuer.keys);
ok(`credential: signed by the issuer it names (${b.issuer && b.issuer.name})`, sv.valid && semnatarEmitent, sv.valid ? (semnatarEmitent ? '' : 'signed by other keys than the named issuer') : sv.reason);
if (trustIssuer) ok('credential: the issuer is the trust root you gave', keyId(publicKeysOf(trustIssuer)) === keyId(b.issuer && b.issuer.keys), 'issuer not trusted');
else checks.push({ name: 'credential: issuer trust', pass: null, detail: 'not judged; pass --trust-issuer <issuer public keys> to require your trust root' });
ok(`credential: names the keys that signed the statement (${b.subject && b.subject.name})`, !!att.signature && keyId(publicKeysOf(att.signature)) === keyId(b.subject && b.subject.keys), att.signature ? 'the statement was signed by other keys' : 'the statement is not signed');
const t = chainTime || att.statement.createdAt; const sursa = chainTime ? 'first seen on chain' : 'createdAt, declared by the builder';
// 2026-09-29 (B-18): data declarata e aleasa de cine tine cheile. Ea poate ACUZA (in afara valabilitatii, dupa revocare: fals), dar nu
// poate ACHITA: inauntru, fara timpul lantului, raspunsul e nejudecat, nu adevarat. Masurat pe 1.4.0: cu cheile unei acreditari
// revocate, o atestare antedatata inaintea revocarii iesea VALIDA, cu "not revoked" trecut.
const declarat = !chainTime;
const nejudecat = 'by the builder\'s own date, which whoever holds the keys chooses; notarize the attestation to judge it on the chain\'s time';
const inValabilitate = Date.parse(t) >= Date.parse(b.validFrom) && Date.parse(t) <= Date.parse(b.validUntil);
if (!inValabilitate || !declarat) ok(`credential: valid at ${t} (${sursa})`, inValabilitate, `valid ${b.validFrom} .. ${b.validUntil}`);
else checks.push({ name: `credential: valid at ${t} (${sursa})`, pass: null, detail: 'inside the validity ' + nejudecat });
for (const rv of revocations || []) {
const rb = rv && rv.body; const rsv = rb ? pq.verify(JSON.stringify(rb), rv.signature || {}) : { valid: false };
const aEmitentului = rb && rsv.valid && keyId(publicKeysOf(rv.signature)) === keyId(b.issuer && b.issuer.keys) && keyId(rb.issuer) === keyId(b.issuer.keys);
if (!aEmitentului) { checks.push({ name: 'credential: a revocation', pass: null, detail: 'ignored: not signed by this credential\'s issuer' }); continue; }
if (rb.credential !== want) continue; // another credential of the same issuer
const inainteDeRevocare = Date.parse(t) < Date.parse(rb.revokedAt);
if (!inainteDeRevocare || !declarat) ok(`credential: not revoked at ${t} (revocation from ${rb.revokedAt})`, inainteDeRevocare, rb.reason || 'revoked');
else checks.push({ name: `credential: not revoked at ${t} (revocation from ${rb.revokedAt})`, pass: null, detail: 'before the revocation ' + nejudecat });
}
if (!(revocations || []).length) checks.push({ name: 'credential: revocations', pass: null, detail: 'none given; a revocation the verifier was not handed cannot be seen' });
}
export function buildStatement({ artifacts, sbom, name, version, sourcePath = null, build = null, base = null, credential = null, cwd = process.cwd(), now = new Date() }) {
if (!artifacts.length) throw new Error('at least one artifact file is required');
const seen = new Set();
const list = artifacts.map((p) => {
const base0 = numeArtefact(p, base);
if (seen.has(base0)) throw new Error(`two artifacts share the name "${base0}"; artifacts are matched by name at verification${base ? '' : ' (use --base to name them by relative path)'}`);
seen.add(base0);
const st = fs.statSync(p);
return { name: base0, sha256: sha256File(p), bytes: st.size };
});
return {
v: 1, kind: 'aere-proof-of-software', tool: TOOL,
subject: { name: name || list[0].name, version: version || null },
...(base ? { artifactNames: 'relative-path' } : {}),
artifacts: list,
sbom: sbom ? { name: path.basename(sbom), sha256: sha256File(sbom), bytes: fs.statSync(sbom).size } : null,
source: gitInfo(cwd, sourcePath),
...(build ? { build: build === 'npm-pack' ? { kind: 'npm-pack', npm: npmVersion() } : { kind: String(build) } } : {}),
builder: { platform: `${process.platform}/${process.arch}`, node: process.version, ...(credential ? { credential: credentialHash(credential) } : {}) },
createdAt: now.toISOString(),
};
}
export async function attest(opts) {
if (opts.credential) {
// attesting under a credential that does not name these keys would produce an attestation that can only fail verification
if (!opts.keys) throw new Error('--credential needs --keys: the credential names the keys that must sign the statement');
if (keyId(publicKeysOf(opts.keys)) !== keyId(opts.credential.body && opts.credential.body.subject && opts.credential.body.subject.keys)) throw new Error('the credential names other keys than --keys');
}
const statement = buildStatement(opts);
const statementJson = JSON.stringify(statement);
const statementHash = sha256Text(statementJson);
const out = { v: 1, kind: 'aere-proof-of-software-attestation', statement, statementHash, signature: null, notarization: null, ...(opts.credential ? { credential: opts.credential } : {}) };
if (opts.keys) out.signature = pq.sign(statementJson, opts.keys);
if (opts.cloud) {
const r = await opts.cloud.notarize(statementHash);
out.notarization = { txHash: r.txHash, block: r.block, firstSeenAt: r.firstSeenAt, firstTime: r.firstTime, contract: r.contract, chainId: r.chainId, proof: '/v1/proof/' + statementHash };
}
return out;
}
// The SBOM re-derived from the committed tree (1.3.0). `npm sbom --package-lock-only` puts a random serial number and a timestamp in
// every run, so the attested file can never be reproduced byte for byte; what can be reproduced is what it SAYS. The semantic form:
// the root, and every component by purl with its hashes and scope, and the dependency graph, all sorted. An SBOM edited by hand
// (a component removed, a version or an integrity hash changed) and attested again passes on its digest and fails here.
export function sbomSemantica(bom) {
// 1.4.0: si proprietatile 'aere:' (un SBOM Go tine acolo hash-ul h1 din go.sum); un SBOM npm nu are asemenea proprietati, deci
// judecata lui ramane aceeasi
const aere = (ps) => (ps || []).filter((x) => String(x.name).startsWith('aere:')).map((x) => `${x.name}=${x.value}`).sort();
// 1.5.0: o componenta fara purl (o referinta de proiect .NET, radacina Gradle) se numeste prin bom-ref
const comp = (c) => ({ purl: c.purl || c['bom-ref'] || `${c.name}@${c.version}`, scope: c.scope || null,
hashes: (c.hashes || []).map((h) => `${h.alg}:${String(h.content).toLowerCase()}`).sort(), props: aere(c.properties) });
const cheie = (x) => JSON.stringify(x);
const componente = (bom.components || []).map(comp).sort((a, b) => (cheie(a) < cheie(b) ? -1 : 1));
const dependente = (bom.dependencies || []).map((d) => ({ ref: d.ref, dependsOn: [...(d.dependsOn || [])].sort() })).sort((a, b) => (a.ref < b.ref ? -1 : 1));
const radacina = bom.metadata && bom.metadata.component ? comp(bom.metadata.component) : null;
return { radacina: radacina && radacina.purl, meta: aere(bom.metadata && bom.metadata.properties), componente, dependente };
}
export function sbomNpmFromTree(repoDir, treeish) {
const src = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-sbom-'));
try {
exportTree(repoDir, treeish, src);
if (!fs.existsSync(path.join(src, 'package-lock.json'))) return { lipsa: 'the committed tree has no package-lock.json, so the SBOM cannot be re-derived from it' };
const out = execFileSync('npm sbom --sbom-format cyclonedx --package-lock-only', { cwd: src, shell: true, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1 << 28 }).toString();
return { bom: JSON.parse(out) };
} catch (e) { return { lipsa: 'npm sbom did not run here: ' + String(e.message || e).slice(0, 100) }; } finally { fs.rmSync(src, { recursive: true, force: true }); }
}
// 1.4.0 (2026-09-29): SBOM-ul unui modul Go, derivat DETERMINIST din go.sum si go.mod (aceleasi fisiere -> aceiasi octeti: fara
// timp, numarul de serie derivat din continut). Componentele sunt modulele al caror CONTINUT e fixat in go.sum (randul fara /go.mod),
// cu hash-ul h1 al lui Go ca proprietate `aere:go-sum-h1`: h1 e un SHA-256 peste un rezumat al fisierelor modulului, nu peste o
// arhiva, deci nu se scrie drept `hashes` SHA-256 (ar spune altceva decat este). Modulele fixate numai prin go.mod (consultate la
// rezolvarea grafului, fara cod descarcat) se numara in `aere:go-sum-go-mod-only`, nu se listeaza. Ce NU spune: go.sum poate tine si
// module ramase de la o versiune veche (pana la `go mod tidy`), deci SBOM-ul spune ce e FIXAT, nu ce s-a compilat.
const GO_SUM_RAND = /^(\S+) (v\S+?)(\/go\.mod)? (h1:[A-Za-z0-9+/]{43}=)$/;
const purlGo = (p, v) => `pkg:golang/${p.split('/').map(encodeURIComponent).join('/')}@${encodeURIComponent(v)}`;
export function sbomGo({ goSum, goMod, version }) {
const mm = /^module\s+"?([^\s"]+)"?\s*$/m.exec(String(goMod || '').replace(/\r/g, ''));
if (!mm) throw new Error('go.mod names no module');
if (!version) throw new Error('an SBOM for a Go module needs --version (the module version is not in go.mod)');
const mods = new Map();
String(goSum || '').replace(/\r/g, '').split('\n').forEach((l, i) => {
if (!l.trim()) return;
const m = GO_SUM_RAND.exec(l);
if (!m) throw new Error(`go.sum line ${i + 1} is not "<module> <version>[/go.mod] h1:<hash>"`);
const k = `${m[1]} ${m[2]}`; const e = mods.get(k) || { path: m[1], version: m[2], h1: null, goMod: null };
if (m[3]) e.goMod = m[4]; else e.h1 = m[4];
mods.set(k, e);
});
const components = [...mods.values()].filter((e) => e.h1).map((e) => {
const purl = purlGo(e.path, e.version);
return { type: 'library', 'bom-ref': purl, name: e.path, version: e.version, purl, properties: [{ name: 'aere:go-sum-h1', value: e.h1 }] };
}).sort((a, b) => (a.purl < b.purl ? -1 : a.purl > b.purl ? 1 : 0));
const doarGoMod = [...mods.values()].filter((e) => !e.h1).length;
const root = purlGo(mm[1], version);
const metadata = {
component: { type: 'application', 'bom-ref': root, name: mm[1], version, purl: root },
tools: { components: [{ type: 'application', name: 'aere-proof-of-software', version: TOOL.split('/')[1] }] },
properties: [{ name: 'aere:derived-from', value: 'go.sum' }, { name: 'aere:go-sum-go-mod-only', value: String(doarGoMod) }],
};
const b = crypto.createHash('sha256').update(JSON.stringify({ metadata, components })).digest();
b[6] = (b[6] & 0x0f) | 0x50; b[8] = (b[8] & 0x3f) | 0x80;
const h = b.subarray(0, 16).toString('hex');
const serialNumber = `urn:uuid:${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
return { bomFormat: 'CycloneDX', specVersion: '1.6', serialNumber, version: 1, metadata, components, dependencies: [] };
}
/** SBOM-ul Go al arborelui COMIS <commit>:<cale> (go.sum si go.mod citite din git, fara unealta Go). */
export function sbomGoFromTree(repoDir, commit, rel, version) {
const blob = (f) => { try { return execFileSync('git', ['cat-file', '-p', `${commit}:${rel}/${f}`], { cwd: repoDir, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1 << 28 }).toString(); } catch { return null; } };
const goMod = blob('go.mod'); if (goMod == null) return { lipsa: 'the committed tree has no go.mod, so the SBOM cannot be re-derived from it' };
const goSum = blob('go.sum'); if (goSum == null) return { lipsa: 'the committed tree has no go.sum, so the SBOM cannot be re-derived from it' };
try { return { bom: sbomGo({ goSum, goMod, version }) }; } catch (e) { return { lipsa: 'the committed go.sum or go.mod cannot be read: ' + String(e.message || e).slice(0, 100) }; }
}
const derivatDin = (bom) => ((bom && bom.metadata && (bom.metadata.properties || []).find((x) => x.name === 'aere:derived-from')) || {}).value || null;
// numarul de serie al unui SBOM derivat: UUID (forma 5) din continut, deci aceleasi fisiere dau aceiasi octeti
function serieDerivata(o) {
const b = crypto.createHash('sha256').update(JSON.stringify(o)).digest();
b[6] = (b[6] & 0x0f) | 0x50; b[8] = (b[8] & 0x3f) | 0x80;
const h = b.subarray(0, 16).toString('hex');
return `urn:uuid:${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
const propr = (o) => Object.entries(o).filter(([, v]) => v != null).map(([name, value]) => ({ name, value: String(value) })).sort((a, b) => (a.name < b.name ? -1 : 1));
const graf = (muchii) => { const m = new Map(); for (const [de, la] of muchii) { if (!m.has(de)) m.set(de, new Set()); m.get(de).add(la); }
return [...m.entries()].map(([ref, s]) => ({ ref, dependsOn: [...s].sort() })).sort((a, b) => (a.ref < b.ref ? -1 : 1)); };
const blobDin = (repoDir, commit, cale) => { try { return execFileSync('git', ['cat-file', '-p', `${commit}:${cale}`], { cwd: repoDir, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1 << 28 }).toString(); } catch { return null; } };
const numeDin = (repoDir, commit, rel) => { try { return execFileSync('git', ['ls-tree', '--name-only', `${commit}:${rel}`], { cwd: repoDir, stdio: ['ignore', 'pipe', 'ignore'] }).toString().split('\n').filter(Boolean); } catch { return null; } };
// 1.5.0 (2026-09-29): SBOM-ul unui proiect .NET, derivat DETERMINIST din packages.lock.json COMIS (scris de NuGet cu
// RestorePackagesWithLockFile). Componentele sunt pachetele din toate sectiunile (cadru, si cadru/RID), cu versiunea REZOLVATA;
// contentHash-ul NuGet e o proprietate (`aere:nuget-content-hash`), nu un `hashes` SHA-512: NuGet il calculeaza peste continutul
// pachetului FARA semnatura de depozit, deci nu e hash-ul fisierului .nupkg descarcat (masurat pe pachete reale semnate). Referintele
// de proiect (tip Project) sunt componente cu bom-ref `project:<nume>`, fara purl. Graful vine din lockfile: fiecare dependinta
// numita se rezolva la versiunea rezolvata a aceluiasi pachet din aceeasi sectiune (sau din cadrul de baza, pentru o sectiune RID).
const NUGET_TIPURI = new Set(['Direct', 'Transitive', 'CentralTransitive', 'Project']);
const NUGET_HASH = /^[A-Za-z0-9+/]{86}==$/;
const purlNuget = (n, v) => `pkg:nuget/${encodeURIComponent(n)}@${encodeURIComponent(v)}`;
export function sbomNuget({ lock, projectName, version }) {
let j; try { j = typeof lock === 'string' ? JSON.parse(lock) : lock; } catch { throw new Error('packages.lock.json is not JSON'); }
if (!j || ![1, 2].includes(j.version) || !j.dependencies || typeof j.dependencies !== 'object') throw new Error('packages.lock.json has no version 1 or 2, or no dependencies');
if (!projectName) throw new Error('an SBOM for a .NET project needs the project name (one project file next to packages.lock.json)');
if (!version) throw new Error('an SBOM for a .NET project needs --version (the project version is not in packages.lock.json)');
const root = purlNuget(projectName, version);
const sectiuni = Object.keys(j.dependencies).sort();
const index = new Map(sectiuni.map((s) => [s, new Map(Object.entries(j.dependencies[s] || {}).map(([n, e]) => [n.toLowerCase(), [n, e]]))]));
const pachete = new Map(), proiecte = new Map(), muchii = [];
const refDe = ([n, e]) => (e.type === 'Project' ? `project:${n}` : purlNuget(n, e.resolved));
for (const s of sectiuni) {
const ix = index.get(s), baza = s.includes('/') ? index.get(s.split('/')[0]) : null;
const rezolva = (dn) => { const x = ix.get(dn.toLowerCase()) || (baza && baza.get(dn.toLowerCase())); if (!x) throw new Error(`packages.lock.json: ${dn} is a dependency in ${s} and is not resolved in it`); return x; };
for (const [lower, [n, e]] of ix) {
if (!e || !NUGET_TIPURI.has(e.type)) throw new Error(`packages.lock.json: ${n} in ${s} has an unknown type ${e && e.type}`);
if (e.type === 'Project') { proiecte.set(lower, n); }
else {
if (typeof e.resolved !== 'string' || !e.resolved) throw new Error(`packages.lock.json: ${n} in ${s} has no resolved version`);
if (!NUGET_HASH.test(String(e.contentHash))) throw new Error(`packages.lock.json: ${n} ${e.resolved} in ${s} has no SHA-512 content hash`);
const k = `${lower}@${e.resolved.toLowerCase()}`;
const p = pachete.get(k) || { name: n, version: e.resolved, hash: e.contentHash, sectiuni: new Set(), tipuri: new Set() };
if (p.hash !== e.contentHash) throw new Error(`packages.lock.json: ${n} ${e.resolved} has two content hashes`);
p.sectiuni.add(s); p.tipuri.add(e.type); pachete.set(k, p);
}
if (e.type === 'Direct' || e.type === 'Project') muchii.push([root, refDe([n, e])]);
for (const dn of Object.keys(e.dependencies || {})) muchii.push([refDe([n, e]), refDe(rezolva(dn))]);
}
}
const components = [
...[...pachete.values()].map((p) => { const purl = purlNuget(p.name, p.version);
return { type: 'library', 'bom-ref': purl, name: p.name, version: p.version, purl,
properties: propr({ 'aere:nuget-content-hash': 'sha512-' + p.hash, 'aere:nuget-frameworks': [...p.sectiuni].sort().join(','), 'aere:nuget-type': [...p.tipuri].sort().join(',') }) }; }),
...[...proiecte.values()].map((n) => ({ type: 'library', 'bom-ref': `project:${n}`, name: n, properties: propr({ 'aere:nuget-type': 'Project' }) })),
].sort((a, b) => (a['bom-ref'] < b['bom-ref'] ? -1 : a['bom-ref'] > b['bom-ref'] ? 1 : 0));
const metadata = {
component: { type: 'application', 'bom-ref': root, name: projectName, version, purl: root },
tools: { components: [{ type: 'application', name: 'aere-proof-of-software', version: TOOL.split('/')[1] }] },
properties: propr({ 'aere:derived-from': 'packages.lock.json', 'aere:nuget-lock-version': j.version, 'aere:nuget-frameworks': sectiuni.join(',') }),
};
const dependencies = graf(muchii);
return { bomFormat: 'CycloneDX', specVersion: '1.6', serialNumber: serieDerivata({ metadata, components, dependencies }), version: 1, metadata, components, dependencies };
}
/** SBOM-ul .NET al arborelui COMIS <commit>:<cale>: packages.lock.json si numele UNUI fisier de proiect din acelasi dosar. */
export function sbomNugetFromTree(repoDir, commit, rel, version) {
const lock = blobDin(repoDir, commit, `${rel}/packages.lock.json`);
if (lock == null) return { lipsa: 'the committed tree has no packages.lock.json, so the SBOM cannot be re-derived from it' };
const proj = (numeDin(repoDir, commit, rel) || []).filter((n) => /\.(cs|fs|vb)proj$/i.test(n));
if (proj.length !== 1) return { lipsa: `the committed directory has ${proj.length} project files; exactly one names the SBOM root` };
try { return { bom: sbomNuget({ lock, projectName: proj[0].replace(/\.(cs|fs|vb)proj$/i, ''), version }) }; } catch (e) { return { lipsa: 'the committed packages.lock.json cannot be read: ' + String(e.message || e).slice(0, 120) }; }
}
// 1.5.0 (2026-09-29): SBOM-ul unui proiect Gradle (un singur proiect), derivat DETERMINIST din gradle/verification-metadata.xml
// COMIS (verificarea dependintelor lui Gradle: fiecare artefact rezolvat cu hash-urile lui) si, daca e comis, din gradle.lockfile
// (coordonatele fiecarei configuratii). Hash-urile artefactului principal (<nume>-<versiune>.jar/.aar) sunt chiar hash-urile fisierului,
// deci intra in `hashes`; toate artefactele (si pom-urile, modulele) stau ca `aere:gradle-artifact`. Ce NU spune: graful de dependinte
// (niciunul din cele doua fisiere nu il tine, deci `dependencies` e gol si metadata o spune); verification-metadata cuprinde tot ce
// rezolva constructia (si pom-uri parinte, si pluginuri), iar configuratiile din gradle.lockfile spun ce ajunge in care classpath.
const XML_ENT = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
const xmlDec = (s) => String(s).replace(/&(amp|lt|gt|quot|apos);/g, (_, e) => XML_ENT[e]);
const CDX_ALG = { sha1: 'SHA-1', sha256: 'SHA-256', sha512: 'SHA-512', md5: 'MD5' };
const purlMaven = (g, n, v) => `pkg:maven/${encodeURIComponent(g)}/${encodeURIComponent(n)}@${encodeURIComponent(v)}`;
// B-22 (2026-09-30): fisierul e al arborelui ATESTAT, deci al celui care atesta, si il citeste verificatorul. Prima forma a lui 1.5.0 il
// citea cu expresii regulate care costau PATRATIC pe forme facute anume (masurat: un tag de 80 KB fara `=` -> 6,9 s, un settings.gradle
// cu 80.000 de randuri goale -> 5 s; la 1 MB, zeci de minute). Cititorul de mai jos trece o SINGURA data prin text (indexOf, fara
// intoarceri): taguri, atribute cu ghilimele duble, text, comentarii si <?...?> sarite; orice altceva (DOCTYPE, CDATA, `<` intr-un tag,
// un tag sau un comentariu neinchis, un atribut fara ghilimele, taguri incrucisate) e REFUZAT cu motivul, nu ghicit.
const NUME_XML = /^[A-Za-z_][\w.:-]*$/;
function atributeXml(s, unde) {
const a = {}; let i = 0; const n = s.length;
const alb = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\r';
while (i < n) {
while (i < n && alb(s[i])) i++;
if (i >= n) break;
let j = i; while (j < n && !alb(s[j]) && s[j] !== '=') j++;
const nume = s.slice(i, j); if (!NUME_XML.test(nume)) throw new Error(`${unde}: an attribute name that is not a name`);
while (j < n && alb(s[j])) j++;
if (s[j] !== '=') throw new Error(`${unde}: attribute ${nume} has no value`);
j++; while (j < n && alb(s[j])) j++;
if (s[j] !== '"') throw new Error(`${unde}: attribute ${nume} is not in double quotes`);
const f = s.indexOf('"', j + 1); if (f === -1) throw new Error(`${unde}: attribute ${nume} is not closed`);
a[nume] = xmlDec(s.slice(j + 1, f)); i = f + 1;
}
return a;
}
function xmlTokeni(x, unde) {
const out = []; let i = 0; const n = x.length;
while (i < n) {
const lt = x.indexOf('<', i);
if (lt === -1) { out.push({ text: x.slice(i) }); break; }
if (lt > i) out.push({ text: x.slice(i, lt) });
if (x.startsWith('<!--', lt)) { const e = x.indexOf('-->', lt + 4); if (e === -1) throw new Error(`${unde}: a comment is not closed`); i = e + 3; continue; }
if (x.startsWith('<?', lt)) { const e = x.indexOf('?>', lt + 2); if (e === -1) throw new Error(`${unde}: a declaration is not closed`); i = e + 2; continue; }
if (x.startsWith('<!', lt)) throw new Error(`${unde}: a DOCTYPE or CDATA section, which Gradle does not write`);
// capatul tagului, cu ghilimelele sarite ca un bloc (un `>` intr-o valoare nu inchide tagul); fiecare caracter vazut o data
let j = lt + 1;
for (;;) {
if (j >= n) throw new Error(`${unde}: a tag is not closed`);
const c = x[j];
if (c === '>') break;
if (c === '<') throw new Error(`${unde}: "<" inside a tag`);
if (c === '"') { const f = x.indexOf('"', j + 1); if (f === -1) throw new Error(`${unde}: a quoted value is not closed`); j = f + 1; continue; }
j++;
}
let corp = x.slice(lt + 1, j);
if (corp[0] === '/') {
const nume = corp.slice(1).trim(); if (!NUME_XML.test(nume)) throw new Error(`${unde}: a closing tag that is not a name`);
out.push({ close: nume });
} else {
const self = corp.endsWith('/'); if (self) corp = corp.slice(0, -1);
let k = 0; while (k < corp.length && !' \t\n\r'.includes(corp[k])) k++;
const nume = corp.slice(0, k); if (!NUME_XML.test(nume)) throw new Error(`${unde}: a tag whose name is not a name`);
out.push({ open: nume, attrs: atributeXml(corp.slice(k), unde), self });
}
i = j + 1;
}
return out;
}
/** Citirea lui gradle/verification-metadata.xml: configuratia (verify-metadata, verify-signatures, regulile de incredere) si componentele. */
export function citesteVerificationMetadata(text) {
const U = 'gradle/verification-metadata.xml';
const tok = xmlTokeni(String(text || ''), U);
const stiva = []; let radacina = false; let verMeta = null, verSemn = null, reguliIncredere = 0;
const coord = new Map(); let comp = null, art = null;
for (const t of tok) {
const sus = stiva[stiva.length - 1];
if (t.text != null) {
const v = t.text.trim(); if (!v) continue;
if (sus === 'verify-metadata' && stiva[stiva.length - 2] === 'configuration') verMeta = v;
else if (sus === 'verify-signatures' && stiva[stiva.length - 2] === 'configuration') verSemn = v;
continue;
}
if (t.open) {
if (!stiva.length) {
if (radacina) throw new Error(`${U}: a second root element <${t.open}>`);
if (t.open !== 'verification-metadata') throw new Error(`${U} has no <verification-metadata> element`);
radacina = true;
}
if (t.open === 'trust' && stiva.includes('configuration')) reguliIncredere++;
if (t.open === 'component') {
if (sus !== 'components') throw new Error(`${U}: a <component> outside <components>`);
const a = t.attrs; if (!a.group || !a.name || !a.version) throw new Error(`${U}: a component without group, name or version`);
const k = `${a.group}:${a.name}:${a.version}`; if (coord.has(k)) throw new Error(`${U}: ${k} twice`);
comp = { group: a.group, name: a.name, version: a.version, arte: [], configuratii: null }; coord.set(k, comp);
} else if (t.open === 'artifact') {
if (sus !== 'component') throw new Error(`${U}: an <artifact> outside a <component>`);
if (!t.attrs.name) throw new Error(`${U}: an artifact of ${comp.group}:${comp.name} has no name`);
art = { nume: t.attrs.name, valori: [], principale: {} }; comp.arte.push(art);
} else if (['sha1', 'sha256', 'sha512', 'md5', 'pgp'].includes(t.open) && sus === 'artifact') {
const v = String(t.attrs.value || '').toLowerCase();
if (v) { art.valori.push(`${t.open}:${v}`); if (t.open !== 'pgp') art.principale[t.open] = v; }
} else if (t.open === 'also-trust' && ['sha1', 'sha256', 'sha512', 'md5'].includes(sus) && stiva[stiva.length - 2] === 'artifact') {
const v = String(t.attrs.value || '').toLowerCase(); if (v) art.valori.push(`also-trust:${v}`);
}
if (!t.self) stiva.push(t.open);
continue;
}
const scos = stiva.pop();
if (scos !== t.close) throw new Error(`${U}: </${t.close}> closes <${scos || 'nothing'}>`);
if (scos === 'artifact') art = null; else if (scos === 'component') comp = null;
}
if (!radacina) throw new Error(`${U} has no <verification-metadata> element`);
if (stiva.length) throw new Error(`${U}: <${stiva[stiva.length - 1]}> is not closed`);
for (const c of coord.values()) for (const a of c.arte) a.linie = `${a.nume} ${a.valori.sort().join(' ')}`;
return { verMeta, verSemn, reguliIncredere, coord };
}
export function sbomGradle({ verificationMetadata, lockfile = null, projectName, version }) {
if (!projectName) throw new Error('an SBOM for a Gradle project needs the project name (rootProject.name in settings.gradle)');
if (!version) throw new Error('an SBOM for a Gradle project needs --version');
const { verMeta, verSemn, reguliIncredere, coord } = citesteVerificationMetadata(verificationMetadata);
let blocate = 0;
if (lockfile != null) {
String(lockfile).replace(/\r/g, '').split('\n').forEach((l, i) => {
const t = l.trim(); if (!t || t.startsWith('#')) return;
const m = /^([^:=\s]+):([^:=\s]+):([^:=\s]+)=([\w,.-]*)$/.exec(t);
if (!m) { if (/^empty=[\w,.-]*$/.test(t)) return; throw new Error(`gradle.lockfile line ${i + 1} is not "<group>:<name>:<version>=<configurations>"`); }
const k = `${m[1]}:${m[2]}:${m[3]}`;
const c = coord.get(k) || { group: m[1], name: m[2], version: m[3], arte: [], configuratii: null };
c.configuratii = m[4].split(',').filter(Boolean).sort(); coord.set(k, c); blocate++;
});
}
const components = [...coord.values()].map((c) => {
const purl = purlMaven(c.group, c.name, c.version);
const princ = c.arte.find((ar) => new RegExp(`^${c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-${c.version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.(jar|aar|klib|war)$`).test(ar.nume));
const hashes = princ ? Object.entries(princ.principale).map(([alg, content]) => ({ alg: CDX_ALG[alg], content })).sort((p, q) => (p.alg < q.alg ? -1 : 1)) : [];
const properties = [
...c.arte.map((ar) => ({ name: 'aere:gradle-artifact', value: ar.linie })),
...(lockfile != null ? [{ name: 'aere:gradle-configurations', value: (c.configuratii || []).join(',') }] : []),
...(!c.arte.length ? [{ name: 'aere:gradle-no-checksum', value: 'true' }] : []),
].sort((p, q) => (p.name + p.value < q.name + q.value ? -1 : 1));
return { type: 'library', 'bom-ref': purl, group: c.group, name: c.name, version: c.version, purl, ...(hashes.length ? { hashes } : {}), properties };
}).sort((a, b) => (a.purl < b.purl ? -1 : a.purl > b.purl ? 1 : 0));
const root = `gradle-project:${projectName}@${version}`;
const metadata = {
component: { type: 'application', 'bom-ref': root, name: projectName, version },
tools: { components: [{ type: 'application', name: 'aere-proof-of-software', version: TOOL.split('/')[1] }] },
properties: propr({ 'aere:derived-from': 'gradle/verification-metadata.xml', 'aere:gradle-lockfile': lockfile != null ? 'gradle.lockfile' : 'absent',
'aere:gradle-verify-metadata': verMeta, 'aere:gradle-verify-signatures': verSemn, 'aere:gradle-trust-rules': reguliIncredere,
'aere:gradle-locked-without-checksum': lockfile != null ? components.filter((c) => c.properties.some((p) => p.name === 'aere:gradle-no-checksum')).length : null,
'aere:dependency-graph': 'absent: neither gradle/verification-metadata.xml nor gradle.lockfile records it' }),
};
return { bomFormat: 'CycloneDX', specVersion: '1.6', serialNumber: serieDerivata({ metadata, components, blocate }), version: 1, metadata, components, dependencies: [] };
}
export const RADACINA_GRADLE = /^[ \t]*rootProject\.name[ \t]*=[ \t]*['"]([^'"\r\n]+)['"][ \t]*\r?$/m;
/** SBOM-ul Gradle al arborelui COMIS <commit>:<cale>: gradle/verification-metadata.xml, gradle.lockfile (daca e), rootProject.name. */
export function sbomGradleFromTree(repoDir, commit, rel, version) {
const vm = blobDin(repoDir, commit, `${rel}/gradle/verification-metadata.xml`);
if (vm == null) return { lipsa: 'the committed tree has no gradle/verification-metadata.xml, so the SBOM cannot be re-derived from it' };
const setari = blobDin(repoDir, commit, `${rel}/settings.gradle`) ?? blobDin(repoDir, commit, `${rel}/settings.gradle.kts`);
// B-22: `^\s*` cu /m trece peste randuri goale si se reia de la fiecare rand (patratic: 80.000 de randuri goale -> 5 s); spatiile
// se cauta numai in rand
const nm = setari && RADACINA_GRADLE.exec(setari);
if (!nm) return { lipsa: 'the committed settings.gradle(.kts) sets no rootProject.name, which names the SBOM root' };
try { return { bom: sbomGradle({ verificationMetadata: vm, lockfile: blobDin(repoDir, commit, `${rel}/gradle.lockfile`), projectName: nm[1], version }) }; }
catch (e) { return { lipsa: 'the committed Gradle verification metadata cannot be read: ' + String(e.message || e).slice(0, 120) }; }
}
function sbomRebuildCheck(att, repoDir, sbomPath, ok, checks) {
const s = att.statement.source;
if (!att.statement.sbom) return;
if (!sbomPath) { checks.push({ name: 'rebuild: SBOM', pass: null, detail: 'the attested SBOM was not handed to verify, so it was not compared with the committed lockfile' }); return; }
let atestat; try { atestat = JSON.parse(fs.readFileSync(sbomPath, 'utf8')); } catch { return ok('rebuild: the attested SBOM parses', false, 'not JSON'); }
// 1.4.0: un SBOM scris de `sbom-go` se re-deriva din go.sum/go.mod comise; 1.5.0: unul scris de `sbom-nuget` din packages.lock.json,
// unul scris de `sbom-gradle` din gradle/verification-metadata.xml (+ gradle.lockfile); oricare altul, din package-lock.json (npm sbom)
// 1.5.0 (B-21, 2026-09-30): pana la 1.4.0 felul il alegea SBOM-ul atestat, deci un SBOM npm editat de mana care spunea
// `aere:derived-from: go.sum` scapa de judecata (NEJUDECAT, verdict VALID, cu motivul "arborele nu are go.mod"), la fel un SBOM Go
// editat caruia i se scotea proprietatea (judecat ca npm, "arborele nu are package-lock.json"). Acum proprietatea e o afirmatie
// despre ARBORE: un fisier numit care nu e in arborele comis e o afirmatie FALSA; iar un SBOM care nu isi numeste fisierul, intr-un
// arbore fara package-lock.json, ramane nejudecat, dar motivul numeste fisierele din care arborele se POATE judeca.
const din = derivatDin(atestat), ver = att.statement.subject && att.statement.subject.version;
const inArbore = (f) => { try { execFileSync('git', ['cat-file', '-e', `${s.commit}:${s.path}/${f}`], { cwd: repoDir, stdio: 'ignore' }); return true; } catch { return false; } };
const UNEALTA = { 'go.sum': 'sbom-go', 'packages.lock.json': 'sbom-nuget', 'gradle/verification-metadata.xml': 'sbom-gradle', 'package-lock.json': 'npm sbom' };
const prezente = Object.keys(UNEALTA).filter(inArbore);
if (din && !inArbore(din)) {
return ok('rebuild: the file the attested SBOM says it was derived from is in the committed tree', false,
`the SBOM says it was derived from ${din}, which ${s.commit.slice(0, 12)}:${s.path} does not have${prezente.length ? `; the tree has ${prezente.join(', ')}` : ''}`);
}
// un SBOM fara fisier numit (npm sbom, alte unelte) sau cu unul pe care verificatorul nu il stie se judeca fata de package-lock.json,
// ca pana acum; fara package-lock.json, dar cu alt fisier cunoscut in arbore, ramane nejudecat si motivul spune din ce se putea judeca
// (un nume necunoscut nu poate scoate un arbore npm de sub judecata: altfel oricare fisier al arborelui, numit, ar fi o scapare)
const FEL = { 'go.sum': 'go', 'packages.lock.json': 'nuget', 'gradle/verification-metadata.xml': 'gradle' };
const fel = typeof din === 'string' && Object.hasOwn(FEL, din) ? FEL[din] : 'npm';
if (fel === 'npm' && !prezente.includes('package-lock.json') && prezente.length) {
const cum = din ? `says it was derived from ${din}, which this verifier does not re-derive,` : 'names no lockfile (npm sbom and other tools do not)';
checks.push({ name: 'rebuild: SBOM', pass: null, detail: `NOT JUDGED although the tree can be: the attested SBOM ${cum} and the committed tree has no package-lock.json; it has ${prezente.join(', ')}, and an SBOM made from it with pos.mjs ${prezente.map((f) => UNEALTA[f]).join(' / ')} is judged here` });
return;
}
const r = fel === 'go' ? sbomGoFromTree(repoDir, s.commit, s.path, ver)
: fel === 'nuget' ? sbomNugetFromTree(repoDir, s.commit, s.path, ver)
: fel === 'gradle' ? sbomGradleFromTree(repoDir, s.commit, s.path, ver)
: sbomNpmFromTree(repoDir, `${s.commit}:${s.path}`);
if (r.lipsa) { checks.push({ name: 'rebuild: SBOM', pass: null, detail: r.lipsa }); return; }
const a = sbomSemantica(atestat), b = sbomSemantica(r.bom);
const aceleasi = JSON.stringify(a) === JSON.stringify(b);
let detaliu = '';
if (!aceleasi) {
const pa = new Set(a.componente.map((c) => JSON.stringify(c))), pb = new Set(b.componente.map((c) => JSON.stringify(c)));
const doarA = [...pa].filter((x) => !pb.has(x)).length, doarB = [...pb].filter((x) => !pa.has(x)).length;
detaliu = `${doarA} component(s) only in the attested SBOM, ${doarB} only in the lockfile; graph ${JSON.stringify(a.dependente) === JSON.stringify(b.dependente) ? 'same' : 'differs'}; root ${a.radacina === b.radacina ? 'same' : 'differs'}`;
}
const spune = { go: 'go.sum pins', nuget: 'packages.lock.json resolves', gradle: 'Gradle verification metadata pins' }[fel] || 'lockfile says';
ok(`rebuild: the attested SBOM says what the committed ${spune} (${b.componente.length} components)`, aceleasi, detaliu);
}
// Rebuild check: from a clone the VERIFIER chose, take the attested commit, require that <commit>:<path> is the attested
// tree, pack that tree again and require the attested artifact's digest. It proves the artifact is what the source
// produces; a statement without source.tree or without build.kind=npm-pack cannot be rebuilt and says so.
function rebuildChecks(att, repoDir, ok, note, sbomPath = null, checks = []) {
const s = att.statement.source, b = att.statement.build;
if (!s || !s.tree || !s.path) return ok('rebuild: the statement names a source tree', false, 'attested without --source-path');
const tree = git(repoDir, ['rev-parse', '--verify', '--quiet', `${s.commit}:${s.path}`]);
if (!ok(`source: ${s.commit.slice(0, 12)}:${s.path} is the attested tree ${s.tree.slice(0, 12)}`, tree === s.tree, tree ? `this clone has ${tree.slice(0, 12)}` : 'commit or path not in this clone')) return false;
// 1.4.0: o atestare fara `npm pack` (un modul Go, un binar construit altfel) nu se poate reconstrui aici, si asta e ABSENT, nu fals;
// arborele si SBOM-ul se judeca oricum (pana la 1.3.0 raspunsul era "fals" si SBOM-ul nu se mai judeca deloc)
if (!b || b.kind !== 'npm-pack') {
checks.push({ name: 'rebuild: artifacts', pass: null, detail: `this tool repeats only npm pack; build ${b ? b.kind : 'not recorded'} was not repeated` });
sbomRebuildCheck(att, repoDir, sbomPath, ok, checks);
return true;
}
const out = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-rebuild-'));
try {
const tgz = packNpmFromTree(repoDir, `${s.commit}:${s.path}`, out);
const a = att.statement.artifacts.find((x) => x.name === path.basename(tgz));
if (!a) return ok(`rebuild: ${path.basename(tgz)} is an attested artifact`, false, 'the rebuilt file name is not in the statement');
const h = sha256File(tgz); const npmNow = npmVersion();
const same = ok(`rebuild: npm pack of the attested tree reproduces ${a.name} byte for byte`, h === a.sha256, h === a.sha256 ? '' : `got ${h} with npm ${npmNow}, attested with npm ${b.npm}`);
if (same && npmNow !== b.npm) note(`rebuild: reproduced with npm ${npmNow} (attested with npm ${b.npm})`);
sbomRebuildCheck(att, repoDir, sbomPath, ok, checks);
return same;
} catch (e) { return ok('rebuild: npm pack of the attested tree ran', false, String(e.message || e).slice(0, 120)); } finally { fs.rmSync(out, { recursive: true, force: true }); }
}
export async function verify(att, files, { cloud = null, rebuildFrom = null, base = null, trustIssuer = null, revocations = [], signer = null } = {}) {
let chainTime = null;
const checks = [];
const ok = (name, pass, detail) => { checks.push({ name, pass, detail: detail || '' }); return pass; };
if (!att || att.kind !== 'aere-proof-of-software-attestation' || !att.statement) return { valid: false, checks: [{ name: 'shape', pass: false, detail: 'not an attestation' }] };
const statementJson = JSON.stringify(att.statement);
ok('statementHash = sha256(statement text)', sha256Text(statementJson) === att.statementHash, att.statementHash);
const relativ = att.statement.artifactNames === 'relative-path';
if (relativ && !base) ok('artifact names', false, 'this attestation names artifacts by relative path: pass --base <directory>');
// un fisier din afara lui --base (de ex. SBOM-ul langa dosarul modelului) se potriveste pe bazenume, ca in forma veche
const cheie = (p) => { if (!(relativ && base)) return path.basename(p); try { return numeArtefact(p, base); } catch { return path.basename(p); } };
const byName = new Map(files.map((p) => [cheie(p), p]));
for (const a of att.statement.artifacts) {
const p = byName.get(a.name);
if (!p) { ok(`artifact ${a.name}: present`, false, 'not given to verify'); continue; }
const h = sha256File(p), b = fs.statSync(p).size;
ok(`artifact ${a.name}: sha256 and size match`, h === a.sha256 && b === a.bytes, h === a.sha256 ? '' : `got ${h}`);
}
if (att.statement.sbom) {
const p = byName.get(att.statement.sbom.name);
if (p) ok(`sbom ${att.statement.sbom.name}: sha256 matches`, sha256File(p) === att.statement.sbom.sha256);
else checks.push({ name: `sbom ${att.statement.sbom.name}`, pass: null, detail: 'not given to verify (absent, not wrong)' });
}
// 2026-09-29 (B-18): o semnatura valida spune ca NISTE chei au semnat, nu CARE. Masurat pe 1.4.0: un strain isi semna cu cheile lui
// declaratia despre artefactul lui (acelasi nume si versiune) si verificarea spunea VALID fara sa numeasca semnatarul. Acum numele
// verificarii poarta amprenta cheilor, `signer` cere cheile asteptate, iar fara el (si fara o acreditare judecata) se spune nejudecat.
const semnatar = att.signature ? keyId(publicKeysOf(att.signature)) : null;
if (att.signature) {
const r = pq.verify(statementJson, att.signature);
ok(`hybrid signature ${att.signature.alg} by keys ${semnatar ? semnatar.slice(0, 18) : '?'}: both halves verify over the statement text`, r.valid, r.reason || '');
} else checks.push({ name: 'hybrid signature', pass: null, detail: 'absent' });
if (signer) ok('signer: the statement is signed by the keys you gave', !!semnatar && semnatar === keyId(publicKeysOf(signer)), semnatar ? `signed by keys ${semnatar.slice(0, 18)}` : 'the statement is not signed');
else if (!(att.statement.builder && att.statement.builder.credential && trustIssuer)) checks.push({ name: 'signer', pass: null, detail: `not judged: anyone can sign a statement with their own keys${semnatar ? ` (these are ${semnatar.slice(0, 18)})` : ''}; pass --signer <public keys>, or --trust-issuer with a developer credential, to require who signed` });
if (att.notarization) {
if (cloud) {
try {
const p = await cloud.proof(att.statementHash);
const notarizat = ok('on chain: statementHash is notarized', p.notarized === true);
const acelasi = ok(`on chain: first appearance matches the receipt (block ${att.notarization.block})`, p.block === att.notarization.block && p.txHash === att.notarization.txHash, `${p.block} ${p.txHash}`);
// the chain's first-seen time (read now from the chain, not from the builder's receipt) judges the credential's validity,
// not the builder's own createdAt; firstSeenAt is unix seconds
if (notarizat && acelasi && Number(p.firstSeenAt) > 0) chainTime = new Date(Number(p.firstSeenAt) * 1000).toISOString();
checks.push({ name: `on chain: finality ${p.finality}${p.pqAnchor ? ' (anchor ' + p.pqAnchor.height + ', ' + p.pqAnchor.falconSeals + ' Falcon + ' + p.pqAnchor.slhDsaSeals + ' SLH-DSA seals)' : ''}`, pass: p.finality === 'post-quantum' ? true : null, detail: p.finality === 'post-quantum' ? '' : 'not yet covered by an anchor; ask again later' });
} catch (e) { ok('on chain: proof readable', false, String(e.message || e).slice(0, 120)); }
} else checks.push({ name: 'on chain', pass: null, detail: 'receipt present; pass a Cloud key to read the proof' });
} else checks.push({ name: 'on chain', pass: null, detail: 'not notarized' });
credentialChecks(att, { trustIssuer, revocations, chainTime }, ok, checks);
if (rebuildFrom) rebuildChecks(att, rebuildFrom, ok, (name) => checks.push({ name, pass: null, detail: '' }), att.statement.sbom ? byName.get(att.statement.sbom.name) || null : null, checks);
else if (att.statement.source && att.statement.source.tree) checks.push({ name: 'rebuild', pass: null, detail: `not attempted; pass --rebuild-from <clone> to repeat the build of tree ${att.statement.source.tree.slice(0, 12)}` });
return { valid: checks.every((c) => c.pass !== false), checks };
}
// ---------------------------------------------------------------- AI provenance: ML-BOM CycloneDX 1.6
// Fisierele modelului se listeaza RECURSIV (fara dosare ascunse), sortate dupa calea relativa; fiecare cu SHA-256 si marime.
// Digestul manifestului = sha256 al randurilor "<cale>\t<sha256>\t<octeti>\n" in ordinea sortata: un singur numar care se
// schimba la orice fisier adaugat, scos sau atins. Arhitectura se citeste din config.json (architectures, model_type) daca exista;
// nimic nu se ghiceste.
export function listaModelului(dir) {
const out = [];
const umbla = (d) => {
for (const e of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
if (e.name.startsWith('.')) continue;
const p = path.join(d, e.name);
if (e.isDirectory()) umbla(p); else if (e.isFile()) out.push(p);
}
};
umbla(dir);
return out.sort((a, b) => { const x = numeArtefact(a, dir), y = numeArtefact(b, dir); return x < y ? -1 : x > y ? 1 : 0; });
}
export function modelBom({ dir, name, version, task, datasets = [], now = new Date(), uuid = crypto.randomUUID() }) {
const files = listaModelului(dir);
if (!files.length) throw new Error(`no files in ${dir}`);
const randuri = files.map((p) => ({ rel: numeArtefact(p, dir), sha: sha256File(p), bytes: fs.statSync(p).size }));
const manifestSha256 = sha256Text(randuri.map((r) => `${r.rel}\t${r.sha}\t${r.bytes}\n`).join(''));
let cfg = null; const cfgPath = path.join(dir, 'config.json');
if (fs.existsSync(cfgPath)) { try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch { cfg = null; } }
const hex = (s) => s.replace(/^0x/, '');
const date = datasets.map((d) => {
const i = d.indexOf('='); if (i < 1) throw new Error(`--dataset takes NAME=FILE, got "${d}"`);
const nume = d.slice(0, i), fis = d.slice(i + 1);
if (!fs.existsSync(fis)) throw new Error(`dataset file not found: ${fis}`);
return { type: 'data', 'bom-ref': 'data:' + nume, name: nume, hashes: [{ alg: 'SHA-256', content: hex(sha256File(fis)) }],
data: [{ type: 'dataset', name: nume, contents: { attachment: { contentType: 'application/octet-stream', content: path.basename(fis) } } }] };
});
const modelParameters = {};
if (task) modelParameters.task = task;
if (cfg && typeof cfg.model_type === 'string') modelParameters.architectureFamily = cfg.model_type;
if (cfg && Array.isArray(cfg.architectures) && typeof cfg.architectures[0] === 'string') modelParameters.modelArchitecture = cfg.architectures[0];
if (date.length) modelParameters.datasets = date.map((d) => ({ ref: d['bom-ref'] }));
const model = {
type: 'machine-learning-model', 'bom-ref': 'model', name: name || path.basename(path.resolve(dir)), ...(version ? { version } : {}),
properties: [{ name: 'aere:manifestSha256', value: manifestSha256 }, { name: 'aere:manifestRule', value: 'sha256 of lines "<relative path>\\t<sha256>\\t<bytes>\\n" sorted by path' }],
modelCard: { modelParameters },
};
const componente = randuri.map((r) => ({ type: 'file', 'bom-ref': 'file:' + r.rel, name: r.rel, hashes: [{ alg: 'SHA-256', content: hex(r.sha) }],
properties: [{ name: 'aere:bytes', value: String(r.bytes) }] }));
const bom = {
bomFormat: 'CycloneDX', specVersion: '1.6', serialNumber: 'urn:uuid:' + uuid, version: 1,
metadata: { timestamp: now.toISOString(), tools: { components: [{ type: 'application', name: 'aere-proof-of-software', version: TOOL.split('/')[1] }] }, component: model },
components: [...componente, ...date],
dependencies: [{ ref: 'model', dependsOn: [...componente.map((c) => c['bom-ref']), ...date.map((d) => d['bom-ref'])] }],
};
return { bom, files, manifestSha256 };
}
// ---------------------------------------------------------------- CLI
function arg(args, name, dflt) { const i = args.indexOf(name); if (i < 0) return dflt; const v = args[i + 1]; args.splice(i, 2); return v; }
function flag(args, name) { const i = args.indexOf(name); if (i < 0) return false; args.splice(i, 1); return true; }
function cloudFrom(file) { if (!file) return null; return new AereCloud({ apiKey: fs.readFileSync(file, 'utf8').trim() }); }
async function main() {
const args = process.argv.slice(2);
const cmd = args.shift();
if (cmd === 'pack-npm') {
const sourcePath = arg(args, '--source-path'); const outDir = arg(args, '--out-dir', '.'); const commit = arg(args, '--commit', 'HEAD');
if (!sourcePath) throw new Error('pack-npm needs --source-path <package directory>');
const top = git(process.cwd(), ['rev-parse', '--show-toplevel']); if (!top) throw new Error('pack-npm needs a git checkout');
const rel = path.relative(path.resolve(top), path.resolve(sourcePath)).split(path.sep).join('/');
console.log(packNpmFromTree(process.cwd(), `${commit}:${rel}`, outDir));
return;
}
if (cmd === 'keygen') {
const out = arg(args, '--out', 'keys.json'); const alg = arg(args, '--alg', 'secp256k1+ml-dsa-65');
fs.writeFileSync(out, JSON.stringify(pq.generateKeyPair({ alg }), null, 1), { mode: 0o600 });
console.log(`keys written to ${out} (${alg}); keep it secret, publish only the public keys`);
return;
}
if (cmd === 'pubkey') { // the public part of a keys file, to hand out (an issuer's trust root, a developer's credential subject)
const keysFile = arg(args, '--keys'); const out = arg(args, '--out');
const pub = publicKeysOf(JSON.parse(fs.readFileSync(keysFile, 'utf8')));
if (!pub) throw new Error('not a keys file');
const s = JSON.stringify(pub, null, 1) + '\n'; if (out) fs.writeFileSync(out, s); else process.stdout.write(s);
return;
}
if (cmd === 'credential') { // credential issue | revoke, signed with the ISSUER's keys (the organization's, not the developer's)
const sub = args.shift(); const issuerKeys = JSON.parse(fs.readFileSync(arg(args, '--issuer-keys'), 'utf8')); const out = arg(args, '--out', 'credential.json');
if (sub === 'issue') {
const days = Number(arg(args, '--valid-days', '365')); const from = arg(args, '--valid-from', new Date().toISOString());
const cred = issueCredential({ issuerKeys, issuerName: arg(args, '--issuer-name'), subjectKeys: JSON.parse(fs.readFileSync(arg(args, '--subject-pub'), 'utf8')),
subjectName: arg(args, '--subject-name'), validFrom: from, validUntil: new Date(Date.parse(from) + days * 86400e3).toISOString() });
fs.writeFileSync(out, JSON.stringify(cred, null, 1)); console.log(`credential for "${cred.body.subject.name}" written to ${out}: ${credentialHash(cred)}`); return;
}
if (sub === 'revoke') {
const rv = revokeCredential({ issuerKeys, credential: JSON.parse(fs.readFileSync(arg(args, '--credential'), 'utf8')), revokedAt: arg(args, '--at', new Date().toISOString()), reason: arg(args, '--reason', '') });
fs.writeFileSync(out, JSON.stringify(rv, null, 1)); console.log(`revocation written to ${out} (from ${rv.body.revokedAt})`); return;
}
throw new Error('credential issue|revoke ...');
}
if (cmd === 'attest') {
const out = arg(args, '--out', 'attestation.json'); const name = arg(args, '--name'); const version = arg(args, '--version');
const sbom = arg(args, '--sbom'); const keysFile = arg(args, '--keys'); const cloudKey = arg(args, '--cloud-key-file');
const sourcePath = arg(args, '--source-path'); const build = arg(args, '--build'); const base = arg(args, '--base');
const credFile = arg(args, '--credential');
const keys = keysFile ? JSON.parse(fs.readFileSync(keysFile, 'utf8')) : null;
const credential = credFile ? JSON.parse(fs.readFileSync(credFile, 'utf8')) : null;
const att = await attest({ artifacts: args, sbom, name, version, sourcePath, build, base, keys, credential, cloud: cloudFrom(cloudKey) });
fs.writeFileSync(out, JSON.stringify(att, null, 1));
console.log(`attestation written to ${out}: ${att.statement.artifacts.length} artifact(s), sbom ${att.statement.sbom ? 'yes' : 'no'}, signature ${att.signature ? att.signature.alg : 'none'}, notarized ${att.notarization ? 'block ' + att.notarization.block : 'no'}`);
console.log(`statementHash ${att.statementHash}`);
return;
}
if (cmd === 'verify') {
const cloudKey = arg(args, '--cloud-key-file'); const rebuildFrom = arg(args, '--rebuild-from'); const base = arg(args, '--base');
const trustFile = arg(args, '--trust-issuer'); const revFiles = arg(args, '--revocations'); const signerFile = arg(args, '--signer'); const file = args.shift();
const att = JSON.parse(fs.readFileSync(file, 'utf8'));
const trustIssuer = trustFile ? JSON.parse(fs.readFileSync(trustFile, 'utf8')) : null;
const revocations = revFiles ? revFiles.split(',').map((f) => JSON.parse(fs.readFileSync(f, 'utf8'))) : [];
const signer = signerFile ? JSON.parse(fs.readFileSync(signerFile, 'utf8')) : null;
const r = await verify(att, args, { cloud: cloudFrom(cloudKey), rebuildFrom, base, trustIssuer, revocations, signer });
for (const c of r.checks) console.log(` ${c.pass === true ? 'OK ' : c.pass === false ? 'FAIL' : '-- '} ${c.name}${c.detail ? ' (' + c.detail + ')' : ''}`);
const nejudecate = r.checks.filter((c) => c.pass === null).length;
console.log(r.valid ? `VALID: every present claim holds${nejudecate ? `; ${nejudecate} not judged (the -- lines)` : ''}` : 'INVALID');
process.exitCode = r.valid ? 0 : 1;
return;
}
if (cmd === 'sbom-go') {
// SBOM-ul Go din arborele COMIS (ca pack-npm): go.mod si go.sum de la <commit>:<cale>, deci exact ce re-deriva verify --rebuild-from
const src = arg(args, '--source-path'); const out = arg(args, '--out', 'sbom.cdx.json'); const version = arg(args, '--version');
const commit = arg(args, '--commit', 'HEAD');
if (!src) throw new Error('sbom-go needs --source-path <directory of go.mod>');
const top = git(process.cwd(), ['rev-parse', '--show-toplevel']); if (!top) throw new Error('sbom-go needs a git checkout');
const rel = path.relative(path.resolve(top), path.resolve(src)).split(path.sep).join('/');
if (!rel || rel.startsWith('..')) throw new Error(`--source-path ${src}: must be a directory below the repository root`);
const r = sbomGoFromTree(top, git(top, ['rev-parse', commit]), rel, version);
if (r.lipsa) throw new Error(r.lipsa);
fs.writeFileSync(out, JSON.stringify(r.bom, null, 1) + '\n');
console.log(`SBOM written to ${out}: ${r.bom.components.length} module(s) pinned in go.sum, ${r.bom.metadata.properties[1].value} pinned by go.mod only (not listed)`);
return;
}
if (cmd === 'sbom-nuget' || cmd === 'sbom-gradle') {
// 1.5.0: din arborele COMIS, ca sbom-go: exact ce re-deriva verify --rebuild-from
const src = arg(args, '--source-path'); const out = arg(args, '--out', 'sbom.cdx.json'); const version = arg(args, '--version');
const commit = arg(args, '--commit', 'HEAD');
if (!src) throw new Error(`${cmd} needs --source-path <directory of ${cmd === 'sbom-nuget' ? 'packages.lock.json' : 'settings.gradle'}>`);
const top = git(process.cwd(), ['rev-parse', '--show-toplevel']); if (!top) throw new Error(`${cmd} needs a git checkout`);
const rel = path.relative(path.resolve(top), path.resolve(src)).split(path.sep).join('/');
if (!rel || rel.startsWith('..')) throw new Error(`--source-path ${src}: must be a directory below the repository root`);
const r = (cmd === 'sbom-nuget' ? sbomNugetFromTree : sbomGradleFromTree)(top, git(top, ['rev-parse', commit]), rel, version);
if (r.lipsa) throw new Error(r.lipsa);
fs.writeFileSync(out, JSON.stringify(r.bom, null, 1) + '\n');
const cu = r.bom.components.filter((c) => c.purl).length;
console.log(`SBOM written to ${out}: ${cu} package(s)${cmd === 'sbom-nuget' ? `, ${r.bom.components.length - cu} project reference(s), ${r.bom.dependencies.length} dependency record(s)` : ', no dependency graph (the Gradle lock files do not record it)'}`);
return;
}
if (cmd === 'model-bom') {
const dir = arg(args, '--model-dir'); const out = arg(args, '--out', 'mlbom.json');
const name = arg(args, '--name'); const version = arg(args, '--version'); const task = arg(args, '--task');
const datasets = []; for (let d; (d = arg(args, '--dataset')) !== undefined;) datasets.push(d);
if (!dir) throw new Error('model-bom needs --model-dir <directory>');
const r = modelBom({ dir, name, version, task, datasets });
fs.writeFileSync(out, JSON.stringify(r.bom, null, 1) + '\n');
console.log(`ML-BOM written to ${out}: ${r.files.length} file(s), ${datasets.length} dataset(s), manifest ${r.manifestSha256}`);
console.log('attest it with: node pos.mjs attest --base ' + dir + ' --sbom ' + out + ' --out attestation.json <the ' + r.files.length + ' files>');
return;
}
console.log('usage: pos.mjs keygen|pack-npm|sbom-go|sbom-nuget|sbom-gradle|attest|verify|model-bom ... (see README.md)');
process.exitCode = 2;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch((e) => { console.error('error:', e.message || e); process.exitCode = 1; });