200 lines
7.9 KiB
JavaScript
200 lines
7.9 KiB
JavaScript
// 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 <image> scripts/sbom.mjs > sbom.cdx.json
|
|
// node scripts/sbom.mjs --root <dir> [--dpkg <status file>] [--name <app>] [--version <v>]
|
|
//
|
|
// 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`)
|
|
}
|