This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
// 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`)
|
||||
}
|
||||
Reference in New Issue
Block a user