Files
Web_FreeCAD_Bitbybit/scripts/generate-sbom.mjs

150 lines
6.9 KiB
JavaScript

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))
}