// Proof of Software 1.4.0: SBOM-ul unui modul Go, derivat determinist din go.mod si go.sum COMISE, si judecat la verify --rebuild-from. // Fixturile sunt fisiere REALE, scrise de unealta Go: go.mod/go.sum din sdk-go (al nostru) si din github.com/golang/mock@v1.6.0 (din // memoria de module a lui Go, un go.sum cu multe module fixate numai prin go.mod). Fiecare afirmatie cu perechea ei negativa. Offline. // node test/sbom-go.test.mjs iesire 0 = toate cum trebuia import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { attest, verify, sbomGo, sbomGoFromTree, sbomSemantica } from '../pos.mjs'; const AICI = path.dirname(fileURLToPath(import.meta.url)); const POS = path.resolve(AICI, '..', 'pos.mjs'); const FX = path.join(AICI, 'fixturi-go'); 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 citeste = (d) => ({ goMod: fs.readFileSync(path.join(FX, d, 'go.mod'), 'utf8'), goSum: fs.readFileSync(path.join(FX, d, 'go.sum'), 'utf8') }); const SDK = citeste('sdk-go'), MOCK = citeste('gomock'); const bSdk = sbomGo({ ...SDK, version: 'v1.0.0' }), bMock = sbomGo({ ...MOCK, version: 'v1.6.0' }); const prop = (c, n) => ((c.properties || []).find((x) => x.name === n) || {}).value; await test('sdk-go: trei module cu continut fixat in go.sum, cu purl pkg:golang si hash-ul h1 ca proprietate; radacina din go.mod', () => { cere(bSdk.components.length === 3, 'componente: ' + bSdk.components.length); cere(bSdk.components.map((c) => c.purl).join(' ') === 'pkg:golang/github.com/cloudflare/circl@v1.6.4 pkg:golang/golang.org/x/crypto@v0.54.0 pkg:golang/golang.org/x/sys@v0.47.0', bSdk.components.map((c) => c.purl).join(' ')); cere(bSdk.metadata.component.purl === 'pkg:golang/aere.network/pqc@v1.0.0', bSdk.metadata.component.purl); cere(bSdk.components.every((c) => /^h1:[A-Za-z0-9+/]{43}=$/.test(prop(c, 'aere:go-sum-h1')) && !(c.hashes || []).length), 'h1 ca proprietate, fara hashes SHA-256'); }); await test('gomock: numai modulele cu continut fixat sunt componente; cele fixate numai prin go.mod se numara (numaratoare independenta)', () => { // numaratoarea independenta, alt cod decat cel masurat: perechi distincte "modul versiune", cu si fara randul de continut const randuri = MOCK.goSum.split('\n').filter(Boolean).map((l) => l.split(' ')); const cuContinut = new Set(randuri.filter((r) => !r[1].endsWith('/go.mod')).map((r) => r[0] + ' ' + r[1])); const toate = new Set(randuri.map((r) => r[0] + ' ' + r[1].replace(/\/go\.mod$/, ''))); cere(bMock.components.length === cuContinut.size && cuContinut.size === 5, `componente ${bMock.components.length}, asteptat ${cuContinut.size}`); cere(Number(prop(bMock.metadata, 'aere:go-sum-go-mod-only')) === toate.size - cuContinut.size && toate.size - cuContinut.size > 10, `numai go.mod: ${prop(bMock.metadata, 'aere:go-sum-go-mod-only')} fata de ${toate.size - cuContinut.size}`); cere(bMock.components.some((c) => c.purl === 'pkg:golang/golang.org/x/tools@v0.1.1'), 'x/tools v0.1.1 lipseste'); }); await test('determinist: aceleasi fisiere dau aceiasi octeti (fara timp; numarul de serie e UUID derivat din continut)', () => { const alt = sbomGo({ ...SDK, version: 'v1.0.0' }); cere(JSON.stringify(alt) === JSON.stringify(bSdk), '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(bSdk.serialNumber), bSdk.serialNumber); cere(sbomGo({ ...SDK, version: 'v1.0.1' }).serialNumber !== bSdk.serialNumber, 'CONTROL: alta versiune, alt numar de serie'); }); await test('CONTROL POZITIV al metodei: hash-ul h1 citit din go.sum e chiar cel pe care Go l-a calculat la descarcarea modulului', () => { const cache = (() => { try { return execFileSync('go', ['env', 'GOMODCACHE'], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch { return null; } })(); const perechi = bSdk.components.map((c) => ({ c, f: cache && path.join(cache, 'cache', 'download', ...c.name.split('/'), '@v', c.version + '.ziphash') })).filter((x) => x.f && fs.existsSync(x.f)); if (!perechi.length) { console.log(' SARIT (nicio arhiva a acestor module in memoria de module Go de pe aceasta masina)'); return; } for (const { c, f } of perechi) cere(fs.readFileSync(f, 'utf8').trim() === prop(c, 'aere:go-sum-h1'), `${c.name}: ${fs.readFileSync(f, 'utf8').trim()} fata de ${prop(c, 'aere:go-sum-h1')}`); console.log(` (${perechi.length} din ${bSdk.components.length} module comparate cu memoria de module Go)`); }); await test('CONTROL: un rand de go.sum care nu are forma lui, sau un go.mod fara modul -> refuzat, nu ghicit', () => { let e1 = null; try { sbomGo({ goMod: SDK.goMod, goSum: SDK.goSum + 'rand stricat\n', version: 'v1' }); } catch (e) { e1 = e.message; } let e2 = null; try { sbomGo({ goMod: 'go 1.21\n', goSum: SDK.goSum, version: 'v1' }); } catch (e) { e2 = e.message; } cere(/go.sum line \d+/.test(e1 || '') && /names no module/.test(e2 || ''), `${e1} | ${e2}`); }); // fluxul intreg, intr-un depozit git temporar: SBOM-ul scris de `sbom-go` din arborele comis, atestat, verificat cu --rebuild-from const G = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-go-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); const M = path.join(G, 'mod'); fs.mkdirSync(M, { recursive: true }); fs.writeFileSync(path.join(M, 'go.mod'), SDK.goMod); fs.writeFileSync(path.join(M, 'go.sum'), SDK.goSum); fs.writeFileSync(path.join(M, 'main.go'), 'package main\n\nfunc main() {}\n'); fs.mkdirSync(path.join(G, 'fara'), { recursive: true }); fs.writeFileSync(path.join(G, 'fara', 'go.mod'), 'module example.com/fara\n\ngo 1.21\n'); g(['add', '-A'], G); g(['commit', '-q', '-m', 'modul de proba'], G); const C = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-go-c-')); g(['clone', '-q', G, C], os.tmpdir()); const OUT = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-pos-go-o-')); const BIN = path.join(OUT, 'app-linux-amd64'); fs.writeFileSync(BIN, 'binarul construit altfel decat cu npm pack'); const S = path.join(OUT, 'app.sbom.cdx.json'); const cli = execFileSync(process.execPath, [POS, 'sbom-go', '--source-path', 'mod', '--version', 'v1.0.0', '--out', S], { cwd: G, stdio: ['ignore', 'pipe', 'pipe'] }).toString(); const att = await attest({ artifacts: [BIN], sbom: S, name: 'app', version: 'v1.0.0', sourcePath: 'mod', cwd: G }); await test('sbom-go (linia de comanda) scrie din arborele COMIS exact SBOM-ul pe care il re-deriva verificarea', () => { const r = sbomGoFromTree(G, g(['rev-parse', 'HEAD'], G), 'mod', 'v1.0.0'); cere(/3 module\(s\) pinned/.test(cli), cli); cere(fs.readFileSync(S, 'utf8') === JSON.stringify(r.bom, null, 1) + '\n', 'fisierul scris difera de derivarea din arbore'); }); await test('verify --rebuild-from: arborele e cel atestat, SBOM-ul spune ce fixeaza go.sum-ul comis, iar artefactele nereconstruite sunt ABSENTE, nu false (VALID)', async () => { const r = await verify(att, [BIN, S], { rebuildFrom: C }); const sb = check(r, /attested SBOM says what the committed go.sum pins/), 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-go-e-')); const f = path.join(d, 'app.sbom.cdx.json'); fs.writeFileSync(f, JSON.stringify(bom, null, 1)); const a = await attest({ artifacts: [BIN], sbom: f, name: 'app', version: 'v1.0.0', sourcePath: 'mod', 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)}`); } await test('CONTROL: un modul scos din SBOM de mana, re-atestat -> prins la reconstructie', () => editat('scos', (b) => { b.components = b.components.filter((c) => !/circl/.test(c.name)); })); await test('CONTROL: o versiune schimbata in SBOM (si purl-ul ei) -> prins', () => editat('versiune', (b) => { const c = b.components.find((x) => /x\/sys/.test(x.name)); c.version = 'v0.48.0'; c.purl = c.purl.replace('v0.47.0', 'v0.48.0'); c['bom-ref'] = c.purl; })); await test('CONTROL: un hash h1 schimbat in SBOM -> prins', () => editat('h1', (b) => { const c = b.components[0]; c.properties = [{ name: 'aere:go-sum-h1', value: 'h1:' + 'A'.repeat(43) + '=' }]; })); await test('CONTROL: SBOM-ul spune alt modul radacina decat go.mod-ul comis -> prins', () => editat('radacina', (b) => { b.metadata.component.name = 'example.com/altul'; b.metadata.component.purl = 'pkg:golang/example.com/altul@v1.0.0'; })); await test('un arbore fara go.sum comis: SBOM-ul nu se poate re-deriva, si se spune (nu e trecut drept VALID)', () => { const r = sbomGoFromTree(G, g(['rev-parse', 'HEAD'], G), 'fara', 'v1'); cere(!r.bom && /no go.sum/.test(r.lipsa || ''), JSON.stringify(r).slice(0, 120)); }); await test('forma semantica: un SBOM npm (fara proprietati aere:) e judecat ca inainte; un SBOM Go poarta h1 in comparatie', () => { const npm = { metadata: { component: { purl: 'pkg:npm/x@1' } }, components: [{ purl: 'pkg:npm/a@1', hashes: [{ alg: 'SHA-512', content: 'AB' }], properties: [{ name: 'cdx:npm:package:path', value: 'node_modules/a' }] }] }; cere(JSON.stringify(sbomSemantica(npm).componente[0].props) === '[]', 'proprietatile npm nu intra'); cere(sbomSemantica(bSdk).componente.every((c) => c.props.length === 1), 'fiecare componenta Go are h1 in forma semantica'); }); 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;