// Proof of Software 1.5.0: SBOM-ul unui proiect .NET din packages.lock.json COMIS si al unui proiect Gradle din // gradle/verification-metadata.xml + gradle.lockfile COMISE, derivate determinist si judecate la verify --rebuild-from. // Fixturile sunt fisiere REALE, scrise de unelte (test/fixturi-nuget/genereaza.sh: .NET SDK 10.0.302, restaurare fara retea din memoria // locala de pachete; test/fixturi-gradle/genereaza.sh: Gradle 8.14.3 --offline). Fiecare afirmatie cu perechea ei negativa. Offline. // node test/sbom-nuget-gradle.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, sbomNuget, sbomNugetFromTree, sbomGradle, sbomGradleFromTree, sbomSemantica, RADACINA_GRADLE } from '../pos.mjs'; const AICI = path.dirname(fileURLToPath(import.meta.url)); const POS = path.resolve(AICI, '..', 'pos.mjs'); const FN = path.join(AICI, 'fixturi-nuget'), FG = path.join(AICI, 'fixturi-gradle'); 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.properties || []).find((x) => x.name === n) || {}).value; const arunca = (f) => { try { f(); return null; } catch (e) { return e.message; } }; const LIB = fs.readFileSync(path.join(FN, 'lib', 'packages.lock.json'), 'utf8'); const TESTS = fs.readFileSync(path.join(FN, 'lib.tests', 'packages.lock.json'), 'utf8'); const VM = fs.readFileSync(path.join(FG, 'gradle', 'verification-metadata.xml'), 'utf8'); const GL = fs.readFileSync(path.join(FG, 'gradle.lockfile'), 'utf8'); const bLib = sbomNuget({ lock: LIB, projectName: 'lib', version: '1.0.0' }); const bTests = sbomNuget({ lock: TESTS, projectName: 'lib.tests', version: '1.0.0' }); const bGr = sbomGradle({ verificationMetadata: VM, lockfile: GL, projectName: 'pos-fixture', version: '1.0.0' }); // ---------------------------------------------------------------- .NET await test('nuget lib: doua pachete, fiecare in ambele cadre (net10.0, net8.0), purl pkg:nuget; radacina din numele proiectului', () => { cere(bLib.components.map((c) => c.purl).join(' ') === 'pkg:nuget/BouncyCastle.Cryptography@2.6.2 pkg:nuget/Newtonsoft.Json@13.0.1', bLib.components.map((c) => c.purl).join(' ')); cere(bLib.components.every((c) => prop(c, 'aere:nuget-frameworks') === 'net10.0,net8.0' && prop(c, 'aere:nuget-type') === 'Direct'), 'cadre sau tip'); cere(bLib.metadata.component.purl === 'pkg:nuget/lib@1.0.0' && prop(bLib.metadata, 'aere:derived-from') === 'packages.lock.json', bLib.metadata.component.purl); }); await test('nuget lib.tests: 90 de pachete (numaratoarea lui NuGet insusi, din .nupkg.metadata-urile restaurarii) si o referinta de proiect', () => { const nuget = fs.readFileSync(path.join(FN, 'nupkg-metadata-contenthash.txt'), 'utf8').split('\n').filter(Boolean); const pachete = bTests.components.filter((c) => c.purl), proiecte = bTests.components.filter((c) => !c.purl); cere(pachete.length === nuget.length && nuget.length === 90, `pachete ${pachete.length}, NuGet ${nuget.length}`); cere(proiecte.length === 1 && proiecte[0]['bom-ref'] === 'project:lib' && prop(proiecte[0], 'aere:nuget-type') === 'Project', JSON.stringify(proiecte)); }); await test('CONTROL POZITIV al metodei: fiecare hash din SBOM e contentHash-ul pe care NuGet l-a scris in .nupkg.metadata la restaurare (90/90)', () => { const nuget = new Map(fs.readFileSync(path.join(FN, 'nupkg-metadata-contenthash.txt'), 'utf8').split('\n').filter(Boolean).map((l) => l.split(' '))); let n = 0; for (const c of bTests.components.filter((x) => x.purl)) { const k = `${c.name.toLowerCase()}/${c.version.toLowerCase()}`; cere(nuget.has(k) && 'sha512-' + nuget.get(k) === prop(c, 'aere:nuget-content-hash'), `${k}: ${nuget.get(k)} fata de ${prop(c, 'aere:nuget-content-hash')}`); cere(!(c.hashes || []).length, `${k}: contentHash-ul NuGet nu e scris drept hash al fisierului`); n++; } cere(n === 90, 'comparate ' + n); }); await test('de ce contentHash e proprietate si nu SHA-512: pe pachetele semnate din memoria locala, SHA-512 al fisierului .nupkg e ALTUL', () => { const cache = path.join(os.homedir(), '.nuget', 'packages'); const perechi = bLib.components.map((c) => ({ c, f: path.join(cache, c.name.toLowerCase(), c.version.toLowerCase(), `${c.name.toLowerCase()}.${c.version.toLowerCase()}.nupkg`) })).filter((x) => fs.existsSync(x.f)); if (!perechi.length) { console.log(' SARIT (pachetele nu sunt in memoria locala NuGet a acestei masini)'); return; } for (const { c, f } of perechi) { const fisier = 'sha512-' + crypto.createHash('sha512').update(fs.readFileSync(f)).digest('base64'); cere(fisier !== prop(c, 'aere:nuget-content-hash'), `${c.name}: hash-ul fisierului e chiar contentHash-ul (atunci ar trebui scris ca SHA-512)`); cere(fs.readFileSync(f + '.sha512', 'utf8').trim() === fisier.slice(7), `${c.name}: .nupkg.sha512 al NuGet nu e SHA-512 al fisierului`); } console.log(` (${perechi.length} pachete: SHA-512 al .nupkg = fisierul .sha512 al NuGet, diferit de contentHash)`); }); await test('nuget: graful din lockfile, cu versiunile REZOLVATE; radacina -> directe si proiectul; proiectul -> pachetele lui', () => { const dep = (ref) => (bTests.dependencies.find((d) => d.ref === ref) || { dependsOn: [] }).dependsOn; cere(dep('pkg:nuget/lib.tests@1.0.0').join(' ') === 'pkg:nuget/Microsoft.NET.Test.Sdk@17.8.0 pkg:nuget/coverlet.collector@6.0.0 pkg:nuget/xunit.runner.visualstudio@2.5.3 pkg:nuget/xunit@2.5.3 project:lib', dep('pkg:nuget/lib.tests@1.0.0').join(' ')); cere(dep('project:lib').join(' ') === 'pkg:nuget/BouncyCastle.Cryptography@2.6.2 pkg:nuget/Newtonsoft.Json@13.0.1', dep('project:lib').join(' ')); cere(dep('pkg:nuget/xunit@2.5.3').join(' ') === 'pkg:nuget/xunit.analyzers@1.4.0 pkg:nuget/xunit.assert@2.5.3 pkg:nuget/xunit.core@2.5.3', dep('pkg:nuget/xunit@2.5.3').join(' ')); const refs = new Set([bTests.metadata.component['bom-ref'], ...bTests.components.map((c) => c['bom-ref'])]); const orfane = bTests.dependencies.flatMap((d) => [d.ref, ...d.dependsOn]).filter((r) => !refs.has(r)); cere(!orfane.length, 'referinte fara componenta: ' + orfane.slice(0, 3).join(', ')); }); await test('determinist: aceleasi fisiere dau aceiasi octeti; alta versiune, alt numar de serie', () => { cere(JSON.stringify(sbomNuget({ lock: TESTS, projectName: 'lib.tests', version: '1.0.0' })) === JSON.stringify(bTests), 'doua derivari .NET difera'); cere(JSON.stringify(sbomGradle({ verificationMetadata: VM, lockfile: GL, projectName: 'pos-fixture', version: '1.0.0' })) === JSON.stringify(bGr), 'doua derivari Gradle difera'); cere(sbomNuget({ lock: TESTS, projectName: 'lib.tests', version: '1.0.1' }).serialNumber !== bTests.serialNumber, 'CONTROL: alta versiune, alt numar de serie'); 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(bGr.serialNumber), bGr.serialNumber); }); await test('CONTROL: un lockfile .NET stricat e refuzat cu motivul, nu ghicit (tip necunoscut, fara hash, dependinta nerezolvata, doua hash-uri)', () => { const j = () => JSON.parse(LIB); const a = j(); a.dependencies['net8.0']['Newtonsoft.Json'].type = 'Floating'; const b = j(); delete b.dependencies['net8.0']['Newtonsoft.Json'].contentHash; const c = j(); c.dependencies['net8.0']['Newtonsoft.Json'].dependencies = { 'System.Nope': '1.0.0' }; const d = j(); d.dependencies['net10.0']['Newtonsoft.Json'].contentHash = 'A'.repeat(86) + '=='; const m = [a, b, c, d].map((x) => arunca(() => sbomNuget({ lock: x, projectName: 'lib', version: '1' }))); cere(/unknown type/.test(m[0] || '') && /no SHA-512 content hash/.test(m[1] || '') && /not resolved/.test(m[2] || '') && /two content hashes/.test(m[3] || ''), m.join(' | ')); }); // ---------------------------------------------------------------- Gradle await test('gradle: cinci componente pkg:maven; jar-ul principal cu SHA-256 si SHA-512 din verification-metadata; pom-ul parinte fara hashes si fara configuratie', () => { cere(bGr.components.map((c) => c.purl).join(' ') === 'pkg:maven/com.google.code.gson/gson-parent@2.10.1 pkg:maven/com.google.code.gson/gson@2.10.1 pkg:maven/org.bouncycastle/bcpkix-jdk18on@1.79 pkg:maven/org.bouncycastle/bcprov-jdk18on@1.79 pkg:maven/org.bouncycastle/bcutil-jdk18on@1.79', bGr.components.map((c) => c.purl).join(' ')); const bcprov = bGr.components.find((c) => c.name === 'bcprov-jdk18on'), parinte = bGr.components.find((c) => c.name === 'gson-parent'); cere((bcprov.hashes || []).map((h) => `${h.alg}:${h.content}`).join(' ').startsWith('SHA-256:0d81ecc3124536b539bce9aa3fe9621b7f84c9cee371b635a5b31c78b79ab1da SHA-512:27bc5415'), JSON.stringify(bcprov.hashes)); cere(!parinte.hashes && prop(parinte, 'aere:gradle-configurations') === '' && /gson-parent-2\.10\.1\.pom sha256:/.test(prop(parinte, 'aere:gradle-artifact')), JSON.stringify(parinte)); cere(prop(bGr.metadata, 'aere:gradle-verify-metadata') === 'true' && prop(bGr.metadata, 'aere:gradle-trust-rules') === '0' && /absent/.test(prop(bGr.metadata, 'aere:dependency-graph')) && !bGr.dependencies.length, JSON.stringify(bGr.metadata.properties)); }); await test('gradle: configuratiile din gradle.lockfile, pe fiecare componenta blocata (numaratoare independenta din lockfile)', () => { const linii = GL.split('\n').filter((l) => /^[^#][^=]*:[^=]*:[^=]*=/.test(l)); cere(linii.length === 4, 'linii ' + linii.length); for (const l of linii) { const [coord, conf] = l.trim().split('='); const [gr, n, v] = coord.split(':'); const c = bGr.components.find((x) => x.group === gr && x.name === n && x.version === v); cere(c && prop(c, 'aere:gradle-configurations') === conf.split(',').sort().join(','), coord); } }); await test('CONTROL POZITIV al metodei: SHA-256 din SBOM e chiar hash-ul jar-ului din memoria locala a lui Gradle', () => { const G = path.join(os.homedir(), '.gradle', 'caches', 'modules-2', 'files-2.1'); let n = 0; for (const c of bGr.components.filter((x) => x.hashes)) { const d = path.join(G, c.group, c.name, c.version); if (!fs.existsSync(d)) continue; const f = fs.readdirSync(d).map((h) => path.join(d, h, `${c.name}-${c.version}.jar`)).find((x) => fs.existsSync(x)); if (!f) continue; const h = crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex'); cere(h === c.hashes.find((x) => x.alg === 'SHA-256').content, `${c.name}: ${h}`); n++; } if (!n) { console.log(' SARIT (jar-urile nu sunt in memoria locala Gradle a acestei masini)'); return; } console.log(` (${n} din ${bGr.components.filter((x) => x.hashes).length} jar-uri comparate cu memoria Gradle)`); }); await test('CONTROL: o coordonata blocata fara hash in verification-metadata e LISTATA si NUMARATA, nu ascunsa', () => { const b = sbomGradle({ verificationMetadata: VM, lockfile: GL + 'org.example:fara-hash:1.0=runtimeClasspath\n', projectName: 'p', version: '1' }); const c = b.components.find((x) => x.name === 'fara-hash'); cere(c && prop(c, 'aere:gradle-no-checksum') === 'true' && !c.hashes && prop(b.metadata, 'aere:gradle-locked-without-checksum') === '1', JSON.stringify(c)); }); await test('CONTROL: regulile care slabesc verificarea (trusted-artifacts) se numara, iar also-trust si pgp apar in linia artefactului', () => { const vm = VM.replace('false', 'false\n ') .replace(/(/, '$1>\n '); const b = sbomGradle({ verificationMetadata: vm, lockfile: GL, projectName: 'p', version: '1' }); const linie = prop(b.components.find((x) => x.name === 'bcprov-jdk18on'), 'aere:gradle-artifact'); const jar = b.components.find((x) => x.name === 'bcprov-jdk18on').properties.filter((p) => p.name === 'aere:gradle-artifact').map((p) => p.value).find((v) => v.startsWith('bcprov-jdk18on-1.79.jar')); cere(prop(b.metadata, 'aere:gradle-trust-rules') === '2', 'reguli ' + prop(b.metadata, 'aere:gradle-trust-rules')); cere(/also-trust:ab/.test(jar) && /pgp:abcd1234/.test(jar), jar + ' | ' + linie); }); await test('CONTROL: verification-metadata cu o componenta necitita, sau un gradle.lockfile stricat -> refuzat cu motivul', () => { const m1 = arunca(() => sbomGradle({ verificationMetadata: VM.replace('', ''), lockfile: GL, projectName: 'p', version: '1' })); const m2 = arunca(() => sbomGradle({ verificationMetadata: VM, lockfile: GL + 'nu e o linie de lockfile\n', projectName: 'p', version: '1' })); const m3 = arunca(() => sbomGradle({ verificationMetadata: '', lockfile: GL, projectName: 'p', version: '1' })); // un element pe care cititorul nu il poate citi deloc (fara atribute) -> refuz, nu o componenta sarita const m4 = arunca(() => sbomGradle({ verificationMetadata: VM.replace('', '\n \n '), lockfile: GL, projectName: 'p', version: '1' })); cere(/without group, name or version/.test(m1 || '') && /gradle.lockfile line \d+/.test(m2 || '') && /no /.test(m3 || '') && /without group, name or version/.test(m4 || ''), [m1, m2, m3, m4].join(' | ')); }); await test('B-22 CONTROL: structura pe care cititorul nu o stie e REFUZATA cu motivul (componenta in afara , taguri incrucisate, tag sau comentariu neinchis, DOCTYPE, atribut fara ghilimele)', () => { const cu = (f) => arunca(() => sbomGradle({ verificationMetadata: f(VM), lockfile: GL, projectName: 'p', version: '1' })) || 'ACCEPTAT'; const m = [ cu((v) => v.replace('', '\n ')), cu((v) => v.replace('', '')), cu((v) => v.replace('', ' v.replace('', '