// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 // // The software bill of materials of a built image, as CycloneDX 1.5 JSON (plan item AT5.1). It reads // what is installed, not what was asked for: every npm package in the image's pnpm store after // `pnpm prune --prod` (so a runtime dependency and nothing else), the Node.js runtime, and the // operating system packages of the base image. THIRD-PARTY-NOTICES is generated from its output // (scripts/third-party-notices.mjs). // // docker run --rm --entrypoint node scripts/sbom.mjs > sbom.cdx.json // node scripts/sbom.mjs --root [--dpkg ] [--name ] [--version ] // // No dependency: the script runs in the image, where only the runtime dependencies are installed. import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs" import { join } from "node:path" import { fileURLToPath } from "node:url" /** The most licence text kept per package. A licence longer than this is a file, not a notice. */ const MAX_LICENCE_BYTES = 64 * 1024 const LICENCE_FILE = /^(licen[cs]e|copying|unlicen[cs]e)(\.|-|$)/i /** The ConusAI packages: their licence is LICENSE-TEMPLATE, whatever their package.json says. */ const CONUSAI_SCOPES = new Set(["@conusai-kit", "@conusai-sdk"]) const isRealDirectory = (path) => { const stat = lstatSync(path, { throwIfNoEntry: false }) return stat?.isDirectory() === true && !stat.isSymbolicLink() } /** The `[name, dir]` of each package a pnpm virtual-store entry holds: real directories, not the links to its dependencies. */ function packagesInStoreEntry(entryNodeModules) { const found = [] for (const name of readdirSync(entryNodeModules).sort()) { const dir = join(entryNodeModules, name) if (name.startsWith("@")) { if (!isRealDirectory(dir)) continue for (const inner of readdirSync(dir).sort()) { if (isRealDirectory(join(dir, inner))) found.push(join(dir, inner)) } } else if (isRealDirectory(dir)) { found.push(dir) } } return found } /** The licence a manifest declares, as a string, or null. */ function declaredLicence(manifest) { const { license, licenses } = manifest if (typeof license === "string" && license !== "") return license if (license && typeof license === "object" && typeof license.type === "string") return license.type if (Array.isArray(licenses)) { const types = licenses.map((entry) => (typeof entry === "string" ? entry : entry?.type)).filter(Boolean) if (types.length > 0) return types.length === 1 ? types[0] : `(${types.join(" OR ")})` } return null } function licenceText(dir) { const file = readdirSync(dir) .sort() .find((name) => LICENCE_FILE.test(name) && lstatSync(join(dir, name)).isFile()) if (!file) return null const bytes = readFileSync(join(dir, file)) if (bytes.length > MAX_LICENCE_BYTES) return null return { file, text: bytes.toString("utf8").replace(/\r\n/g, "\n").trim() } } const repositoryUrl = (manifest) => { const repository = manifest.repository const url = typeof repository === "string" ? repository : repository?.url return typeof url === "string" ? url.replace(/^git\+/, "").replace(/\.git$/, "") : null } const purlOf = (name, version) => `pkg:npm/${name.startsWith("@") ? `%40${name.slice(1)}` : name}@${version}` /** One CycloneDX component for an installed npm package. */ function npmComponent(dir) { const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) const { name, version } = manifest if (typeof name !== "string" || typeof version !== "string") return null const scope = name.startsWith("@") ? name.slice(0, name.indexOf("/")) : undefined const declared = declaredLicence(manifest) const evidence = licenceText(dir) const conusai = scope !== undefined && CONUSAI_SCOPES.has(scope) const component = { type: "library", "bom-ref": purlOf(name, version), ...(scope === undefined ? {} : { group: scope }), name: scope === undefined ? name : name.slice(scope.length + 1), version, purl: purlOf(name, version), licenses: declared === null ? [] : /[\s()]/.test(declared) ? [{ expression: declared }] : [ { license: { ...(declared.startsWith("LicenseRef-") ? { name: declared } : { id: declared }), ...(evidence ? { text: { contentType: "text/plain", content: evidence.text } } : {}), }, }, ], properties: [ ...(conusai ? [{ name: "conusai:component", value: "true" }] : []), ...(evidence ? [{ name: "licence-file", value: evidence.file }] : []), ], } const url = repositoryUrl(manifest) if (url) component.externalReferences = [{ type: "vcs", url }] return component } /** Every npm package installed under `root`'s pnpm store, once per name@version, sorted. */ export function npmComponents(root) { const store = join(root, "node_modules", ".pnpm") const unique = new Map() if (!existsSync(store)) return [] for (const entry of readdirSync(store).sort()) { const nodeModules = join(store, entry, "node_modules") if (!existsSync(nodeModules)) continue for (const dir of packagesInStoreEntry(nodeModules)) { if (!existsSync(join(dir, "package.json"))) continue const component = npmComponent(dir) if (component && !unique.has(component["bom-ref"])) unique.set(component["bom-ref"], component) } } return [...unique.values()].sort((a, b) => a["bom-ref"].localeCompare(b["bom-ref"])) } /** The installed packages of a dpkg status file, as CycloneDX components. */ export function dpkgComponents(statusFile, docRoot = "/usr/share/doc") { if (!existsSync(statusFile)) return [] const found = [] for (const stanza of readFileSync(statusFile, "utf8").split("\n\n")) { const field = (name) => new RegExp(`^${name}: (.*)$`, "m").exec(stanza)?.[1] const name = field("Package") const version = field("Version") if (!name || !version || !/install ok installed/.test(field("Status") ?? "")) continue const copyright = join(docRoot, name, "copyright") found.push({ type: "library", "bom-ref": `pkg:deb/debian/${name}@${version}`, name, version, purl: `pkg:deb/debian/${name}@${version}`, licenses: [], properties: [ { name: "conusai:layer", value: "os" }, ...(existsSync(copyright) ? [{ name: "licence-file", value: copyright }] : []), ], }) } return found.sort((a, b) => a["bom-ref"].localeCompare(b["bom-ref"])) } /** The CycloneDX document for the image whose application sits at `root`. */ export function sbom({ root, dpkg = "/var/lib/dpkg/status", name = "app", version = "0.0.0", nodeVersion = process.version, }) { const runtime = { type: "platform", "bom-ref": `pkg:generic/nodejs@${nodeVersion.replace(/^v/, "")}`, name: "node", version: nodeVersion.replace(/^v/, ""), purl: `pkg:generic/nodejs@${nodeVersion.replace(/^v/, "")}`, licenses: [{ license: { id: "MIT" } }], properties: [{ name: "licence-file", value: "/usr/local/LICENSE" }], } return { bomFormat: "CycloneDX", specVersion: "1.5", version: 1, metadata: { component: { type: "application", name, version } }, components: [runtime, ...npmComponents(root), ...dpkgComponents(dpkg)], } } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { const args = process.argv.slice(2) const option = (flag, fallback) => { const at = args.indexOf(flag) return at === -1 ? fallback : args[at + 1] } const root = option("--root", process.cwd()) const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) const document = sbom({ root, dpkg: option("--dpkg", "/var/lib/dpkg/status"), name: option("--name", manifest.name ?? "app"), version: option("--version", manifest.version ?? "0.0.0"), }) process.stdout.write(`${JSON.stringify(document, null, 2)}\n`) }