// Proof of Software 1.6.0: SBOM-ul unui proiect Maven din lockfile.json COMIS (pluginul maven-lockfile, io.github.chains-project) si // pom.xml de langa el, derivat determinist si judecat la verify --rebuild-from. Fixturile sunt fisiere REALE scrise de plugin, extrase cu // `git show` din depozitul public chains-project/maven-lockfile la 083b879a (test/fixturi-maven/README.md: care, de unde, amprentele). // Numaratorile de referinta se fac AICI, cu alt cod decat cel masurat (o parcurgere proprie a lockfile-ului). Fiecare afirmatie cu // perechea ei negativa. Offline. Nimic din afara probelor nu are voie sa arunce: un paznic scos de controlul negativ trebuie sa // inroseasca o proba, nu sa opreasca suita inainte de rezumat. // node test/sbom-maven.test.mjs iesire 0 = toate cum trebuia 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 { attest, verify, sbomMaven, sbomMavenFromTree, sbomSemantica, citestePom, purlMavenQ } from '../pos.mjs'; const AICI = path.dirname(fileURLToPath(import.meta.url)); const POS = path.resolve(AICI, '..', 'pos.mjs'); const FM = path.join(AICI, 'fixturi-maven'); let treceri = 0; const esecuri = []; async function test(nume, fn) { try { await fn(); treceri++; console.log(' OK ' + nume); } catch (e) { esecuri.push(nume); console.log(' ESEC ' + nume + ' — ' + (e.message || e)); } } const cere = (c, m) => { if (!c) throw new Error(m); }; const g = (args, cwd) => execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); const check = (r, re) => r.checks.find((c) => re.test(c.name)); const prop = (c, n) => ((c && c.properties || []).find((x) => x.name === n) || {}).value; const arunca = (f) => { try { f(); return null; } catch (e) { return e.message; } }; const incearca = (f) => { try { return f(); } catch (e) { return { eroare: String(e.message || e) }; } }; // pentru functiile asincrone (attest): o respingere prinsa aici, nu la nivelul modulului, unde ar opri suita inainte de rezumat (STRICAT, nu ROSU) const incearcaA = async (f) => { try { return await f(); } catch (e) { return { eroare: String(e.message || e) }; } }; const bun = (b) => { cere(b && !b.eroare, b && b.eroare); return b; }; const citeste = (d) => ({ lock: fs.readFileSync(path.join(FM, d, 'lockfile.json'), 'utf8'), pom: fs.readFileSync(path.join(FM, d, 'pom.xml')) }); const ML = citeste('maven-lockfile'), MA = citeste('reactor/module-a'), CL = citeste('classifier'); const bML = incearca(() => sbomMaven(ML)), bMA = incearca(() => sbomMaven(MA)), bCL = incearca(() => sbomMaven(CL)); const J = JSON.parse(ML.lock); const rol = (b, x) => (b.components || []).filter((c) => (prop(c, 'aere:maven-role') || '').split(',').includes(x)); const dep = (b, ref) => ((b.dependencies || []).find((d) => d.ref === ref) || { dependsOn: [] }).dependsOn; // parcurgerea INDEPENDENTA a lockfile-ului (alt cod decat pos.mjs): fiecare nod cu rolul si parintele lui function umblaLock(j) { const out = []; const umbla = (n, r, parinte) => { out.push({ n, r, parinte }); for (const c of n.children || []) umbla(c, r, n); }; for (const d of j.dependencies || []) umbla(d, 'dependency', null); for (const p of j.mavenPlugins || []) for (const d of p.dependencies || []) umbla(d, 'plugin-dependency', p); for (const p of j.mavenExtensions || []) for (const d of p.dependencies || []) umbla(d, 'extension-dependency', p); return out; } // purl scris de mana (fixturile nu au caractere de codificat); tipul jar nu se scrie, calificatorii in ordinea cheilor const cheia = (n, v = n.version) => { const q = [n.classifier ? 'classifier=' + n.classifier : null, n.type && n.type !== 'jar' ? 'type=' + n.type : null].filter(Boolean); return `pkg:maven/${n.groupId}/${n.artifactId}@${v}` + (q.length ? '?' + q.join('&') : ''); }; const cheiaPom = (p) => `pkg:maven/${p.groupId}/${p.artifactId}@${p.version}?type=pom`; // componenta = nod inclus, sau nod fara selectedVersion (inregistrat dinamic, fara mediere); altfel e scos de mediere const esteComponenta = (n) => n.included === true || n.selectedVersion == null; const N = umblaLock(J); const artefacte = new Set(N.filter(({ n }) => esteComponenta(n)).map(({ n }) => cheia(n))); const mediate = N.filter(({ n }) => !esteComponenta(n)); const pomuriIndep = new Set(); const pomIndep = (p) => { for (let x = p; x; x = x.parent) { pomuriIndep.add(cheiaPom(x)); for (const b of x.boms || []) pomIndep(b); } }; for (const { n } of N) { pomIndep(n.parentPom); for (const b of n.boms || []) pomIndep(b); } for (const p of J.mavenPlugins || []) pomIndep(p.parentPom); for (const b of J.boms || []) pomIndep(b); pomIndep(J.pom && J.pom.parent); await test('maven-lockfile: radacina din lockfile (groupId:artifactId:version, purl pkg:maven), derived-from lockfile.json; --version o suprascrie si schimba numarul de serie, versiunea proiectului ramane scrisa', () => { bun(bML); cere(bML.metadata.component.purl === 'pkg:maven/io.github.chains-project/maven-lockfile@5.18.7-SNAPSHOT' && bML.metadata.component.group === 'io.github.chains-project' && bML.metadata.component.name === 'maven-lockfile', bML.metadata.component.purl); cere(prop(bML.metadata, 'aere:derived-from') === 'lockfile.json' && prop(bML.metadata, 'aere:maven-lockfile-plugin') === '5.18.6' && prop(bML.metadata, 'aere:maven-checksum-mode') === 'remote' && prop(bML.metadata, 'aere:maven-packaging') === 'maven-plugin', JSON.stringify(bML.metadata.properties)); const alt = bun(incearca(() => sbomMaven({ ...ML, version: '9.9.9' }))); cere(alt.metadata.component.purl === 'pkg:maven/io.github.chains-project/maven-lockfile@9.9.9' && alt.serialNumber !== bML.serialNumber && prop(alt.metadata, 'aere:maven-project-version') === '5.18.7-SNAPSHOT', alt.metadata.component.purl); }); await test('componentele = artefactele INCLUSE din toti arborii (proiectul si cele 17 pluginuri) + cele inregistrate dinamic (fara selectedVersion), o data pe purl; numaratoare independenta; niciun nod scos de mediere nu e componenta; omise numarate', () => { bun(bML); const dinArbori = new Set([...rol(bML, 'dependency'), ...rol(bML, 'plugin-dependency'), ...rol(bML, 'extension-dependency')].map((c) => c.purl)); cere(artefacte.size === 325 && dinArbori.size === artefacte.size && [...artefacte].every((k) => dinArbori.has(k)), `independent ${artefacte.size}, in SBOM ${dinArbori.size}`); const scoaseCaComponente = mediate.filter(({ n }) => !artefacte.has(cheia(n)) && bML.components.some((c) => c.purl === cheia(n))); cere(!scoaseCaComponente.length, 'noduri scoase de mediere ajunse componente: ' + scoaseCaComponente.slice(0, 3).map(({ n }) => n.id).join(', ')); const conflict = mediate.filter(({ n }) => n.selectedVersion !== n.version).length; cere(mediate.length === 528 && prop(bML.metadata, 'aere:maven-omitted-for-conflict') === String(conflict) && prop(bML.metadata, 'aere:maven-omitted-for-duplicate') === String(mediate.length - conflict), `mediate ${mediate.length}: conflict ${conflict}, SBOM ${prop(bML.metadata, 'aere:maven-omitted-for-conflict')}/${prop(bML.metadata, 'aere:maven-omitted-for-duplicate')}`); const dinamice = new Set(N.filter(({ n }) => n.selectedVersion == null).map(({ n }) => cheia(n))); const marcate = bML.components.filter((c) => prop(c, 'aere:maven-recorded') === 'dynamic').map((c) => c.purl); cere(dinamice.size === 7 && marcate.length === 7 && marcate.every((p) => dinamice.has(p)) && prop(bML.metadata, 'aere:maven-dynamic') === '7', `dinamice independent ${dinamice.size}, marcate ${marcate.length}`); cere(dinamice.has('pkg:maven/org.apache.maven.surefire/surefire-junit-platform@3.6.0'), 'furnizorul surefire adus dinamic lipseste din lista independenta'); }); await test('purl-uri verificate de mana: gson simplu, guice cu clasificator, un BOM cu type=pom, clasificator+tip in ordinea cheilor (exemplele din purl-spec), tipul jar nu se scrie; sources si javadoc ale aceluiasi artefact sunt doua componente', () => { bun(bML); bun(bCL); const gson = bML.components.find((c) => c.purl === 'pkg:maven/com.google.code.gson/gson@2.14.0'); cere(gson && gson.group === 'com.google.code.gson' && gson.name === 'gson' && gson.version === '2.14.0' && gson['bom-ref'] === gson.purl, JSON.stringify(gson)); cere(bML.components.some((c) => c.purl === 'pkg:maven/com.google.inject/guice@5.1.0?classifier=classes'), 'guice:classes'); const bom = bML.components.find((c) => c.purl === 'pkg:maven/org.junit/junit-bom@5.11.0-M2?type=pom'); cere(bom && prop(bom, 'aere:maven-role') === 'bom' && bom.hashes && bom.hashes[0].content === '4a3ffc4a4edcfec2cb5d619922706a9407161798571939f854dfda73e4e17cc8', JSON.stringify(bom)); cere(purlMavenQ('g', 'a', '1', 'dist', 'zip') === 'pkg:maven/g/a@1?classifier=dist&type=zip' && purlMavenQ('g', 'a', '1', null, 'jar') === 'pkg:maven/g/a@1' && purlMavenQ('net.sf.jacob-projec', 'jacob', '1.14.3', 'x86', 'dll') === 'pkg:maven/net.sf.jacob-projec/jacob@1.14.3?classifier=x86&type=dll', 'purlMavenQ'); // fixtura are artefactul simplu SI cele doua clasificate: trei componente, trei checksum-uri (fara clasificator in purl s-ar suprapune) const cl = bCL.components.filter((c) => c.name === 'junit-jupiter-api').map((c) => c.purl).sort(); cere(cl.join(' ') === 'pkg:maven/org.junit.jupiter/junit-jupiter-api@5.9.2 pkg:maven/org.junit.jupiter/junit-jupiter-api@5.9.2?classifier=javadoc pkg:maven/org.junit.jupiter/junit-jupiter-api@5.9.2?classifier=sources', cl.join(' ')); cere(new Set(bCL.components.filter((c) => c.name === 'junit-jupiter-api').map((c) => c.hashes[0].content)).size === 3, 'cele trei nu au trei checksum-uri distincte'); }); await test('hashes: fiecare componenta cu checksum poarta SHA-256 egal cu checksum-ul din lockfile (617/617, numaratoare independenta); fara checksum (modul din acelasi reactor) -> fara hashes, marcat si numarat', () => { bun(bML); bun(bMA); const indep = new Map(); for (const { n } of N) if (esteComponenta(n)) indep.set(cheia(n), n.checksum); const pom = (p) => { for (let x = p; x; x = x.parent) { indep.set(cheiaPom(x), x.checksum); for (const b of x.boms || []) pom(b); } }; for (const { n } of N) { pom(n.parentPom); for (const b of n.boms || []) pom(b); } for (const p of J.mavenPlugins || []) { indep.set(cheia(p), p.checksum); pom(p.parentPom); } cere(indep.size === 617 && bML.components.length === 617, `independent ${indep.size}, SBOM ${bML.components.length}`); let n = 0; for (const c of bML.components) { cere(c.hashes && c.hashes.length === 1 && c.hashes[0].alg === 'SHA-256' && c.hashes[0].content === indep.get(c.purl), `${c.purl}: ${JSON.stringify(c.hashes)} fata de ${indep.get(c.purl)}`); n++; } cere(n === 617 && prop(bML.metadata, 'aere:maven-without-checksum') === '0', 'comparate ' + n); const mb = bMA.components.find((c) => c.name === 'module-b'); cere(mb && !mb.hashes && prop(mb, 'aere:maven-no-checksum') === 'true' && prop(mb, 'aere:maven-scope') === 'compile' && prop(bMA.metadata, 'aere:maven-without-checksum') === '2', JSON.stringify(bMA.components)); }); await test('CONTROL POZITIV al metodei: pom.checksum din lockfile e chiar SHA-256 al pom.xml COMIS (calculat aici, nu in pos.mjs), deci aere:maven-pom-matches-lockfile masoara; un octet in plus in pom -> false; checksum gol -> unrecorded', () => { bun(bML); bun(bMA); bun(bCL); cere(crypto.createHash('sha256').update(ML.pom).digest('hex') === J.pom.checksum && J.pom.checksumAlgorithm === 'SHA-256', 'pom.checksum nu e SHA-256 al pom.xml comis'); cere(prop(bML.metadata, 'aere:maven-pom-matches-lockfile') === 'true' && prop(bCL.metadata, 'aere:maven-pom-matches-lockfile') === 'true', 'potrivirea'); const alt = bun(incearca(() => sbomMaven({ lock: ML.lock, pom: Buffer.concat([ML.pom, Buffer.from('\n')]) }))); cere(prop(alt.metadata, 'aere:maven-pom-matches-lockfile') === 'false' && alt.serialNumber !== bML.serialNumber, 'CONTROL: un pom schimbat trebuia sa dea false'); cere(prop(bMA.metadata, 'aere:maven-pom-matches-lockfile') === 'unrecorded', prop(bMA.metadata, 'aere:maven-pom-matches-lockfile')); }); await test('CONTROL POZITIV al metodei: SHA-256 din SBOM e chiar hash-ul jar-ului din memoria locala Maven (~/.m2; sarit daca lipseste)', () => { bun(bML); const M2 = path.join(os.homedir(), '.m2', 'repository'); let n = 0; for (const c of rol(bML, 'dependency')) { if (/[?]/.test(c.purl)) continue; const f = path.join(M2, ...c.group.split('.'), c.name, c.version, `${c.name}-${c.version}.jar`); if (!fs.existsSync(f)) continue; cere(crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex') === c.hashes[0].content, `${c.purl}: hash-ul fisierului difera`); n++; } if (!n) { console.log(' SARIT (niciun jar al acestor coordonate in memoria locala Maven a acestei masini; masuratoarea pe registru e in fixturi-maven/README.md)'); return; } console.log(` (${n} jar-uri comparate cu memoria locala Maven)`); }); await test('graful: radacina -> nodurile de sus; parinte -> copil; un copil scos de mediere trimite la versiunea ALEASA (nu la cea scoasa); plugin -> dependintele lui; fara referinte orfane', () => { bun(bML); const root = bML.metadata.component['bom-ref']; const tinta = (n) => (esteComponenta(n) ? cheia(n) : cheia(n, n.selectedVersion)); cere(dep(bML, root).join(' ') === [...new Set(J.dependencies.map(tinta))].sort().join(' '), 'radacina: ' + dep(bML, root).length); for (const { n, parinte } of mediate) { const p = parinte ? cheia(parinte) : root; cere(dep(bML, p).includes(cheia(n, n.selectedVersion)), `${n.id} scos de mediere: parintele ${p} nu depinde de versiunea aleasa ${n.selectedVersion}`); } const ex = mediate.find(({ n }) => n.selectedVersion !== n.version && !artefacte.has(cheia(n))); cere(ex && !dep(bML, cheia(ex.parinte)).includes(cheia(ex.n)), ex ? `${ex.n.id}: muchia catre versiunea scoasa nu are voie sa existe` : 'niciun conflict cu versiunea scoasa absenta dintre componente'); const pl = J.mavenPlugins[0]; cere(dep(bML, cheia(pl)).join(' ') === [...new Set(pl.dependencies.map(tinta))].sort().join(' ') && pl.artifactId === 'spotless-maven-plugin', 'pluginul ' + pl.artifactId); const refs = new Set([root, ...bML.components.map((c) => c['bom-ref'])]); const orfane = bML.dependencies.flatMap((d) => [d.ref, ...d.dependsOn]).filter((r) => !refs.has(r)); cere(!orfane.length && bML.dependencies.length === 218, 'orfane ' + orfane.length + ', inregistrari ' + bML.dependencies.length); }); await test('scope-urile si rolurile se aduna pe componenta: aceeasi coordonata in arborele proiectului si al unui plugin e O componenta cu doua roluri (13) si cu scope-urile amandurora (23 cu doua scope-uri); un plugin are rol plugin, hash, depozit, fara scope', () => { bun(bML); const roluri = new Map(), scopuri = new Map(); for (const { n, r } of N) if (esteComponenta(n)) { const k = cheia(n); roluri.set(k, (roluri.get(k) || new Set()).add(r)); scopuri.set(k, (scopuri.get(k) || new Set()).add(n.scope)); } const douaRoluri = [...roluri.entries()].filter(([, s]) => s.size > 1), douaScopuri = [...scopuri.entries()].filter(([, s]) => s.size > 1); cere(douaRoluri.length === 13 && douaRoluri.every(([k]) => prop(bML.components.find((c) => c.purl === k), 'aere:maven-role') === 'dependency,plugin-dependency'), 'doua roluri: ' + douaRoluri.length); cere(douaScopuri.length === 23 && douaScopuri.every(([k, s]) => prop(bML.components.find((c) => c.purl === k), 'aere:maven-scope') === [...s].sort().join(',')), 'doua scope-uri: ' + douaScopuri.length); cere([...scopuri.entries()].every(([k, s]) => prop(bML.components.find((c) => c.purl === k), 'aere:maven-scope') === [...s].sort().join(',')), 'scope-ul unei componente difera de reuniunea independenta'); const pl = bML.components.find((c) => c.name === 'spotless-maven-plugin'); cere(pl && prop(pl, 'aere:maven-role') === 'plugin' && prop(pl, 'aere:maven-scope') == null && pl.hashes && prop(pl, 'aere:maven-repository') === 'central', JSON.stringify(pl)); }); await test('pluginurile (17) si pom-urile (parinte si BOM, type=pom, cu hash; numaratoare independenta) sunt componente cu rolul lor; radacina nu depinde de pluginuri; pom-urile nu au muchii', () => { bun(bML); cere(rol(bML, 'plugin').length === 17 && J.mavenPlugins.length === 17 && prop(bML.metadata, 'aere:maven-plugins') === '17', 'pluginuri ' + rol(bML, 'plugin').length); const pomuri = new Set([...rol(bML, 'parent-pom'), ...rol(bML, 'bom')].map((c) => c.purl)); cere(pomuri.size === pomuriIndep.size && [...pomuriIndep].every((k) => pomuri.has(k)) && pomuriIndep.size === 275, `pom-uri independent ${pomuriIndep.size}, SBOM ${pomuri.size}`); const root = bML.metadata.component['bom-ref'], pluginuri = new Set(rol(bML, 'plugin').map((c) => c.purl)); cere(!dep(bML, root).some((r) => pluginuri.has(r)), 'radacina depinde de un plugin'); const tinte = new Set(bML.dependencies.flatMap((d) => d.dependsOn)); cere(![...pomuri].some((p) => tinte.has(p) || bML.dependencies.some((d) => d.ref === p)), 'un pom are muchii'); }); await test('determinist: aceleasi fisiere dau aceiasi octeti (trei fixturi); numarul de serie e UUID derivat din continut; alta versiune, alt numar de serie', () => { bun(bML); bun(bMA); bun(bCL); cere(JSON.stringify(bun(incearca(() => sbomMaven(ML)))) === JSON.stringify(bML) && JSON.stringify(bun(incearca(() => sbomMaven(MA)))) === JSON.stringify(bMA) && JSON.stringify(bun(incearca(() => sbomMaven(CL)))) === JSON.stringify(bCL), 'doua derivari difera'); cere(/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(bML.serialNumber) && !('timestamp' in bML.metadata), bML.serialNumber); cere(bun(incearca(() => sbomMaven({ ...CL, version: '2' }))).serialNumber !== bCL.serialNumber, 'CONTROL: alta versiune, alt numar de serie'); }); await test('CONTROL: un lockfile stricat e refuzat cu motivul, nu ghicit (nu e JSON, alta versiune de lockfile, nod fara versiune, id care nu numeste g:a:v, scope necunoscut, checksum care nu e hex, doua checksum-uri, mediat cu copii, inclus cu alta versiune aleasa, mediat catre o versiune pe care n-o are nimeni)', () => { const j = () => JSON.parse(CL.lock); const cu = (x) => arunca(() => sbomMaven({ lock: x, pom: CL.pom })) || 'ACCEPTAT'; const a = j(); a.lockFileVersion = 2; const b = j(); delete b.dependencies[0].version; const c = j(); c.dependencies[0].id = 'org.junit.jupiter:junit-jupiter-api:5.9.3'; const d = j(); d.dependencies[0].scope = 'floating'; const e = j(); e.dependencies[0].checksum = 'zz' + e.dependencies[0].checksum.slice(2); const f = JSON.parse(ML.lock); { const vazute = new Set(); let gata = false; for (const { n } of umblaLock(f)) { if (gata || !esteComponenta(n)) continue; const k = cheia(n); if (vazute.has(k)) { n.checksum = 'a'.repeat(64); gata = true; } vazute.add(k); } cere(gata, 'fixtura: nicio coordonata inclusa de doua ori'); } const h = j(); { const m = umblaLock(h).find(({ n }) => !esteComponenta(n)); m.n.children = [JSON.parse(JSON.stringify(h.dependencies[0]))]; } const i = j(); i.dependencies[0].selectedVersion = '5.9.9'; const k = j(); { const m = umblaLock(k).find(({ n }) => !esteComponenta(n)); m.n.selectedVersion = '9.9.9'; } const m = [cu('{'), cu(a), cu(b), cu(c), cu(d), cu(e), cu(f), cu(h), cu(i), cu(k)]; cere(/not JSON/.test(m[0]) && /lockFileVersion 2/.test(m[1]) && /no groupId, artifactId or version/.test(m[2]) && /does not name/.test(m[3]) && /unknown scope/.test(m[4]) && /not SHA-256 hex/.test(m[5]) && /two different checksums/.test(m[6]) && /still has children/.test(m[7]) && /names another selected version/.test(m[8]) && /points to .* which no included node has/.test(m[9]), m.join(' | ')); const p1 = arunca(() => sbomMaven({ lock: CL.lock, pom: 'x' })) || 'ACCEPTAT', p2 = arunca(() => sbomMaven({ lock: CL.lock, pom: '' })) || 'ACCEPTAT', p3 = arunca(() => sbomMaven({ lock: CL.lock, pom: null })) || 'ACCEPTAT'; cere(/pom.xml: is not closed/.test(p1) && /no element/.test(p2) && /needs the committed pom.xml/.test(p3), [p1, p2, p3].join(' | ')); }); await test('citestePom: coordonatele mostenite din , packaging, ale proiectului; modulele din profile nu se iau; refuzuri cu motivul', () => { const r = citestePom(fs.readFileSync(path.join(FM, 'reactor', 'pom.xml'), 'utf8')), a = citestePom(fs.readFileSync(path.join(FM, 'reactor', 'module-a', 'pom.xml'), 'utf8')); cere(r.packaging === 'pom' && r.modules.join(',') === 'module-b,module-a' && r.groupId === 'com.mycompany.app', JSON.stringify(r)); cere(a.artifactId === 'module-a' && a.groupId === 'com.mycompany.app' && a.version === '1.0.0-SNAPSHOT' && a.parent.artifactId === 'reactor-snapshot-sibling-parent' && !a.modules.length, JSON.stringify(a)); const p = citestePom('\nxnuda'); cere(p.modules.join(',') === 'da' && p.groupId === null, JSON.stringify(p)); cere(/names no artifactId/.test(arunca(() => citestePom('')) || '') && /DOCTYPE/.test(arunca(() => citestePom('')) || ''), 'refuzuri'); }); await test('forma semantica: componentele Maven intra cu purl, hash si proprietatile aere: (rol, scope); radacina prin purl', () => { bun(bML); const s = sbomSemantica(bML); cere(s.radacina === 'pkg:maven/io.github.chains-project/maven-lockfile@5.18.7-SNAPSHOT' && s.componente.every((c) => c.hashes.length === 1 && c.props.some((p) => p.startsWith('aere:maven-role='))) && s.meta.some((p) => p === 'aere:maven-pom-matches-lockfile=true'), JSON.stringify(s.componente[0])); }); // ---------------------------------------------------------------- fluxul intreg, intr-un depozit git temporar const G = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-mv-g-')); g(['init', '-q'], G); g(['config', 'user.email', 'proba@aere.invalid'], G); g(['config', 'user.name', 'proba'], G); g(['config', 'core.autocrlf', 'false'], G); fs.cpSync(path.join(FM, 'maven-lockfile'), path.join(G, 'java'), { recursive: true }); fs.cpSync(path.join(FM, 'reactor'), path.join(G, 'reactor'), { recursive: true }); fs.cpSync(path.join(FM, 'classifier'), G, { recursive: true }); // lockfile.json si pom.xml la RADACINA depozitului fs.mkdirSync(path.join(G, 'cu-module', 'module-a'), { recursive: true }); // un pom cu care are si lockfile.json; numai module-a are lockfile fs.copyFileSync(path.join(FM, 'reactor', 'pom.xml'), path.join(G, 'cu-module', 'pom.xml')); fs.copyFileSync(path.join(FM, 'reactor', 'module-a', 'lockfile.json'), path.join(G, 'cu-module', 'lockfile.json')); fs.copyFileSync(path.join(FM, 'reactor', 'module-a', 'lockfile.json'), path.join(G, 'cu-module', 'module-a', 'lockfile.json')); fs.mkdirSync(path.join(G, 'fara-pom')); fs.copyFileSync(path.join(FM, 'classifier', 'lockfile.json'), path.join(G, 'fara-pom', 'lockfile.json')); fs.mkdirSync(path.join(G, 'nimic')); fs.writeFileSync(path.join(G, 'nimic', 'README'), 'fara maven\n'); g(['add', '-A'], G); g(['commit', '-q', '-m', 'proiecte Maven de proba'], G); const HEAD = g(['rev-parse', 'HEAD'], G); const C = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-mv-c-')); g(['clone', '-q', G, C], os.tmpdir()); const OUT = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-mv-o-')); const BIN = path.join(OUT, 'app.jar'); fs.writeFileSync(BIN, 'binarul construit altfel decat cu npm pack'); const S = path.join(OUT, 'java.sbom.cdx.json'), SR = path.join(OUT, 'radacina.sbom.cdx.json'); const cli = (args) => incearca(() => execFileSync(process.execPath, [POS, 'sbom-maven', ...args], { cwd: G, stdio: ['ignore', 'pipe', 'pipe'] }).toString()); const cliJ = cli(['--source-path', 'java', '--out', S]), cliR = cli(['--source-path', '.', '--out', SR]); const attJ = await incearcaA(() => attest({ artifacts: [BIN], sbom: S, name: 'maven-lockfile', sourcePath: 'java', cwd: G })); const attR = await incearcaA(() => attest({ artifacts: [BIN], sbom: SR, name: 'classifier-dependency-check-correct', version: '1', sourcePath: '.', cwd: G })); await test('sbom-maven (linia de comanda) scrie din arborele COMIS exact SBOM-ul pe care il re-deriva verificarea, si spune ce a numarat; --version e optional', () => { cere(typeof cliJ === 'string' && /71 dependency artifact\(s\), 17 plugin\(s\) with 267 plugin dependencies, 275 POM\(s\)\/BOM\(s\); 617 of 617 components with a file hash; 218 dependency record\(s\); 159 node\(s\) omitted for conflict, 369 for duplicate; pom.xml matches the lockfile: true; modules: none/.test(cliJ), JSON.stringify(cliJ)); cere(fs.readFileSync(S, 'utf8') === JSON.stringify(sbomMavenFromTree(G, HEAD, 'java', null).bom, null, 1) + '\n', 'fisierul scris difera de derivarea din arbore'); }); await test('--source-path . : un lockfile.json la RADACINA depozitului (cazul obisnuit Maven) se deriva, se atesta cu source.path "." si se re-deriva din clona', async () => { cere(typeof cliR === 'string' && /6 dependency artifact\(s\), 0 plugin\(s\)/.test(cliR), JSON.stringify(cliR)); cere(fs.readFileSync(SR, 'utf8') === JSON.stringify(sbomMavenFromTree(G, HEAD, '.', '1').bom, null, 1) + '\n', 'fisierul scris la radacina difera de derivarea din arbore'); cere(!attR.eroare && attR.statement.source.path === '.' && attR.statement.source.tree === g(['rev-parse', 'HEAD:'], G), JSON.stringify(attR.eroare || attR.statement.source)); const r = await verify(attR, [BIN, SR], { rebuildFrom: C }); const sb = check(r, /attested SBOM says what the committed lockfile.json \(maven-lockfile\) resolves \(7 components\)/), arb = check(r, /is the attested tree/); cere(r.valid && sb && sb.pass === true && arb && arb.pass === true, JSON.stringify(r.checks.filter((c) => c.pass !== true))); }); await test('verify --rebuild-from: SBOM-ul Maven spune ce rezolva lockfile.json-ul comis (VALID; artefactele nereconstruite ABSENTE, nu false)', async () => { cere(!attJ.eroare, attJ.eroare); const r = await verify(attJ, [BIN, S], { rebuildFrom: C }); const sb = check(r, /attested SBOM says what the committed lockfile.json \(maven-lockfile\) resolves \(617 components\)/), art = check(r, /^rebuild: artifacts$/); cere(r.valid && sb && sb.pass === true && art && art.pass === null, JSON.stringify(r.checks.filter((c) => c.pass !== true))); }); async function editat(nume, schimba) { const bom = JSON.parse(fs.readFileSync(S, 'utf8')); schimba(bom); const d = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-mv-e-')); const f = path.join(d, 'x.sbom.cdx.json'); fs.writeFileSync(f, JSON.stringify(bom, null, 1)); const a = await attest({ artifacts: [BIN], sbom: f, name: 'maven-lockfile', sourcePath: 'java', cwd: G }); const simplu = await verify(a, [BIN, f]); const r = await verify(a, [BIN, f], { rebuildFrom: C }); fs.rmSync(d, { recursive: true, force: true }); cere(simplu.valid, `${nume}: fara reconstructie, digestul singur trece (de asta conteaza comparatia)`); const sb = check(r, /attested SBOM says/); cere(!r.valid && sb && sb.pass === false, `${nume}: trebuia prins; ${JSON.stringify(sb)}`); } const comp = (b, purl) => b.components.find((c) => c.purl === purl); await test('CONTROL: o componenta scoasa din SBOM de mana, re-atestat -> prins', () => editat('scos', (b) => { b.components = b.components.filter((c) => c.name !== 'gson'); })); await test('CONTROL: o versiune schimbata (si purl-ul) -> prins', () => editat('versiune', (b) => { const c = comp(b, 'pkg:maven/com.google.code.gson/gson@2.14.0'); c.version = '2.14.1'; c.purl = c['bom-ref'] = 'pkg:maven/com.google.code.gson/gson@2.14.1'; })); await test('CONTROL: un SHA-256 schimbat -> prins', () => editat('hash', (b) => { comp(b, 'pkg:maven/com.google.code.gson/gson@2.14.0').hashes[0].content = '0'.repeat(64); })); await test('CONTROL: radacina schimbata -> prins', () => editat('radacina', (b) => { b.metadata.component.name = 'altul'; b.metadata.component.purl = b.metadata.component['bom-ref'] = 'pkg:maven/io.github.chains-project/altul@5.18.7-SNAPSHOT'; })); await test('CONTROL: o muchie scoasa din graf -> prins', () => editat('graf', (b) => { const d = b.dependencies.find((x) => x.ref === b.metadata.component['bom-ref']); d.dependsOn = d.dependsOn.slice(1); })); await test('CONTROL: scope-ul unei componente schimbat (compile -> test) -> prins', () => editat('scope', (b) => { comp(b, 'pkg:maven/com.google.code.gson/gson@2.14.0').properties.find((p) => p.name === 'aere:maven-scope').value = 'test'; })); await test('CONTROL: un plugin scos -> prins', () => editat('plugin', (b) => { b.components = b.components.filter((c) => c.name !== 'spotless-maven-plugin'); })); await test('CONTROL: "pom.xml matches the lockfile" rescris de mana -> prins', () => editat('pom', (b) => { b.metadata.properties.find((p) => p.name === 'aere:maven-pom-matches-lockfile').value = 'false'; })); await test('B-21: un SBOM Maven editat, fara aere:derived-from -> nejudecat, cu motivul care numeste lockfile.json si sbom-maven (nu "nu se poate re-deriva")', async () => { const bom = JSON.parse(fs.readFileSync(S, 'utf8')); bom.components = bom.components.filter((c) => c.name !== 'gson'); bom.metadata.properties = bom.metadata.properties.filter((p) => p.name !== 'aere:derived-from'); const d = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-mv-e-')); const f = path.join(d, 'x.sbom.cdx.json'); fs.writeFileSync(f, JSON.stringify(bom, null, 1)); const a = await attest({ artifacts: [BIN], sbom: f, name: 'maven-lockfile', sourcePath: 'java', cwd: G }); const r = await verify(a, [BIN, f], { rebuildFrom: C }); fs.rmSync(d, { recursive: true, force: true }); const sb = check(r, /^rebuild: SBOM$/); cere(sb && sb.pass === null && /names no lockfile/.test(sb.detail) && /it has lockfile\.json/.test(sb.detail) && /sbom-maven/.test(sb.detail), JSON.stringify(sb)); }); await test('modulele: pom-ul agregator fara lockfile refuza pe nume si numeste modulele; un pom cu si lockfile spune care module au lockfile.json comis; lockfile fara pom.xml, sau niciun Maven -> spus, nu trecut', () => { const a = sbomMavenFromTree(G, HEAD, 'reactor', '1'), b = sbomMavenFromTree(G, HEAD, 'fara-pom', '1'), c = sbomMavenFromTree(G, HEAD, 'nimic', '1'), d = sbomMavenFromTree(G, HEAD, 'cu-module', null); cere(/aggregator with modules module-b, module-a/.test(a.lipsa || '') && /per module/.test(a.lipsa || ''), JSON.stringify(a).slice(0, 200)); cere(/no pom.xml next to it/.test(b.lipsa || '') && /no lockfile.json/.test(c.lipsa || '') && /maven-lockfile plugin/.test(c.lipsa || ''), [b.lipsa, c.lipsa].join(' | ')); cere(d.bom && prop(d.bom.metadata, 'aere:maven-modules') === 'module-b,module-a' && prop(d.bom.metadata, 'aere:maven-modules-with-lockfile') === 'module-a' && prop(d.bom.metadata, 'aere:maven-packaging') === 'pom', JSON.stringify(d.lipsa || d.bom.metadata.properties)); const ma = sbomMavenFromTree(G, HEAD, 'reactor/module-a', null); cere(ma.bom && ma.bom.metadata.component.purl === 'pkg:maven/com.mycompany.app/module-a@1.0.0-SNAPSHOT' && prop(ma.bom.metadata, 'aere:maven-modules') === 'none' && rol(ma.bom, 'parent-pom').length === 1, JSON.stringify(ma.lipsa || ma.bom.metadata)); }); for (const d of [G, C, OUT]) fs.rmSync(d, { recursive: true, force: true }); console.log(`\n${treceri} treceri, ${esecuri.length} esecuri`); process.exitCode = esecuri.length ? 1 : 0;