Files
template-app/scripts/sbom.mjs
T

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