150 lines
6.9 KiB
JavaScript
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))
|
|
}
|