import { createHash } from 'node:crypto' import { readFile, writeFile } from 'node:fs/promises' import { resolve } from 'node:path' const root = resolve(new URL('..', import.meta.url).pathname) const lockPath = resolve(root, 'package-lock.json') const packagePath = resolve(root, 'package.json') const outputPath = resolve(root, 'config/sbom.cdx.json') const checkOnly = process.argv.includes('--check') const readJson = async (path) => JSON.parse(await readFile(path, 'utf8')) const packageNameFromPath = (packagePathName) => { const marker = 'node_modules/' const index = packagePathName.lastIndexOf(marker) const relative = packagePathName.slice(index + marker.length) const parts = relative.split('/') return parts[0].startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] } const purlFor = (name, version) => `pkg:npm/${encodeURIComponent(name)}@${version}` const manifestFor = async (packagePathName) => { try { return await readJson(resolve(root, packagePathName, 'package.json')) } catch { return undefined } } const licenseEntries = (packageManifest) => { const declared = packageManifest?.license ?? packageManifest?.licenses const values = Array.isArray(declared) ? declared : declared ? [declared] : [] return values.flatMap((value) => { const text = typeof value === 'string' ? value : value?.type ?? value?.name if (!text) return [] return /^[A-Za-z0-9.+-]+$/.test(text) ? [{ license: { id: text } }] : [{ license: { name: text } }] }) } const lock = await readJson(lockPath) const manifest = await readJson(packagePath) if (lock.lockfileVersion !== 3) throw new Error(`SBOM requires npm lockfileVersion 3 (got ${lock.lockfileVersion}).`) const lockPackages = lock.packages ?? {} const rootRef = purlFor(manifest.name, manifest.version) const packageEntries = Object.entries(lockPackages) .filter(([path, value]) => path.startsWith('node_modules/') && value?.version) .sort(([left], [right]) => left.localeCompare(right)) const components = [] const packageRefsByPath = new Map() const metadataByPath = new Map() for (const [packagePathName, lockEntry] of packageEntries) { const name = packageNameFromPath(packagePathName) if (name !== lockEntry.name && lockEntry.name) throw new Error(`SBOM package path/name mismatch: ${packagePathName} -> ${lockEntry.name}`) const ref = purlFor(name, lockEntry.version) packageRefsByPath.set(packagePathName, ref) const packageManifest = await manifestFor(packagePathName) const installed = Boolean(packageManifest) if (installed && packageManifest.version !== lockEntry.version) throw new Error(`SBOM installed version mismatch for ${name}: ${packageManifest.version} != ${lockEntry.version}`) const component = { type: 'library', 'bom-ref': ref, name, version: lockEntry.version, scope: lockEntry.optional ? 'optional' : lockEntry.dev ? 'development' : 'required', purl: ref, hashes: [{ alg: 'SHA-512', content: lockEntry.integrity.replace(/^sha512-/, '') }], externalReferences: [{ type: 'distribution', url: lockEntry.resolved }], } const licenses = licenseEntries(packageManifest) if (licenses.length) component.licenses = licenses component.properties = [ { name: 'bitbybit:installed', value: String(installed) }, { name: 'bitbybit:license-status', value: licenses.length ? 'declared' : installed ? 'undisclosed' : 'not-installed-optional' }, ] components.push(component) metadataByPath.set(packagePathName, { lockEntry, ref }) } const resolveDependencyPath = (fromPath, dependencyName) => { const candidates = [] let current = fromPath while (current) { candidates.push(`${current}/node_modules/${dependencyName}`) const parent = current.lastIndexOf('/node_modules/') if (parent < 0) break current = current.slice(0, parent) } candidates.push(`node_modules/${dependencyName}`) return candidates.find((candidate) => packageRefsByPath.has(candidate)) } const dependencyRecords = [{ ref: rootRef, dependsOn: [] }] for (const [packagePathName, { lockEntry, ref }] of metadataByPath) { const dependencies = { ...(lockEntry.dependencies ?? {}), ...(lockEntry.optionalDependencies ?? {}) } const dependsOn = Object.keys(dependencies).sort().flatMap((dependencyName) => { const resolvedPath = resolveDependencyPath(packagePathName, dependencyName) return resolvedPath ? [packageRefsByPath.get(resolvedPath)] : [] }) dependencyRecords.push({ ref, dependsOn: [...new Set(dependsOn)].sort() }) } const directDependencies = [...new Set([ ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.devDependencies ?? {}), ])].sort() for (const dependencyName of directDependencies) { const directPath = `node_modules/${dependencyName}` if (!packageRefsByPath.has(directPath)) throw new Error(`SBOM direct dependency is not locked: ${dependencyName}`) dependencyRecords[0].dependsOn.push(packageRefsByPath.get(directPath)) } dependencyRecords[0].dependsOn = [...new Set(dependencyRecords[0].dependsOn)].sort() const lockHash = createHash('sha256').update(await readFile(lockPath)).digest('hex') const rootComponent = { type: 'application', 'bom-ref': rootRef, name: manifest.name, version: manifest.version, purl: rootRef, properties: [{ name: 'bitbybit:license-status', value: manifest.license ? 'declared' : 'undisclosed' }], } const document = { bomFormat: 'CycloneDX', specVersion: '1.5', version: 1, metadata: { tools: [{ vendor: 'BitBybit Web CAD', name: 'generate-sbom.mjs', version: '1' }], component: rootComponent, properties: [ { name: 'bitbybit:source', value: 'package-lock.json' }, { name: 'bitbybit:lockfile-sha256', value: lockHash }, { name: 'bitbybit:package-manager', value: 'npm lockfileVersion 3' }, ], }, components, dependencies: dependencyRecords.sort((left, right) => left.ref.localeCompare(right.ref)), } const serialized = `${JSON.stringify(document, null, 2)}\n` if (checkOnly) { let existing try { existing = await readFile(outputPath, 'utf8') } catch { throw new Error(`SBOM artifact is missing: ${outputPath}`) } if (existing !== serialized) throw new Error('SBOM artifact is stale; run `npm run generate:sbom` and review the result.') const undisclosed = components.filter((component) => component.properties?.some((property) => property.name === 'bitbybit:license-status' && property.value === 'undisclosed')) if (undisclosed.length) throw new Error(`SBOM has ${undisclosed.length} installed packages without declared licenses.`) console.log(JSON.stringify({ status: 'sbom-pass', components: components.length, optionalNotInstalled: components.filter((component) => component.properties?.some((property) => property.value === 'not-installed-optional')).length, lockfileSha256: lockHash }, null, 2)) } else { await writeFile(outputPath, serialized) console.log(JSON.stringify({ status: 'sbom-generated', output: outputPath, components: components.length, lockfileSha256: lockHash }, null, 2)) }