Files
template-app/scripts/third-party-notices.mjs

164 lines
7.4 KiB
JavaScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
//
// THIRD-PARTY-NOTICES, generated from the software bill of materials of the built image (plan item
// AT5.1; scripts/sbom.mjs writes it). It lists every component the image runs, with its licence and,
// for an npm package, the text of the licence file the package ships, grouped so that one text that
// 200 packages share is printed once.
//
// node scripts/third-party-notices.mjs <sbom.cdx.json> [--out THIRD-PARTY-NOTICES]
// node scripts/third-party-notices.mjs <sbom.cdx.json> --check [--out THIRD-PARTY-NOTICES]
//
// `--check` fails when the file is not what the SBOM generates; `--complete` fails when a component of the
// SBOM is not listed by name, which is what "lists every runtime dependency of the built image" is
// checked against the bundle-specific lock and package versions used to build the image.
// The output is a pure function of the SBOM: no date, no host name, no order that depends on a walk.
import { createHash } from "node:crypto"
import { existsSync, readFileSync, writeFileSync } from "node:fs"
import { fileURLToPath } from "node:url"
const property = (component, name) => component.properties?.find((entry) => entry.name === name)?.value
const nameOf = (component) => (component.group ? `${component.group}/${component.name}` : component.name)
const labelOf = (component) => `${nameOf(component)}@${component.version}`
/** The licence a component declares, as one string. */
function licenceOf(component) {
const entry = component.licenses?.[0]
if (!entry) return "NOT DECLARED"
if (entry.expression) return entry.expression
return entry.license?.id ?? entry.license?.name ?? "NOT DECLARED"
}
const textOf = (component) => component.licenses?.[0]?.license?.text?.content ?? null
const sha = (text) => createHash("sha256").update(text).digest("hex")
const rule = "=".repeat(78)
/** The notices text for an SBOM document. */
export function notices(document) {
const components = document.components ?? []
const app = document.metadata?.component
const conusai = components.filter((component) => property(component, "conusai:component") === "true")
const os = components.filter((component) => property(component, "conusai:layer") === "os")
const runtime = components.filter((component) => component.type === "platform")
const npm = components.filter(
(component) => !conusai.includes(component) && !os.includes(component) && !runtime.includes(component),
)
const lines = [
"THIRD-PARTY NOTICES",
"",
`This file lists the software in the image ${app ? `${app.name} ${app.version} ` : ""}is built into, and the licence of each part.`,
"It is generated from the software bill of materials of the built image (CycloneDX, scripts/sbom.mjs) by",
"scripts/third-party-notices.mjs; do not edit it by hand. Regenerate it when the dependencies change:",
"",
" docker run --rm --entrypoint node <image> scripts/sbom.mjs > sbom.cdx.json",
" node scripts/third-party-notices.mjs sbom.cdx.json",
"",
`Components: ${components.length} (${conusai.length} ConusAI, ${npm.length} npm, ${runtime.length} runtime, ${os.length} operating system).`,
"",
rule,
`1. ConusAI components (${conusai.length})`,
rule,
"",
"Licensed under the ConusAI Template Licence 1.0 (LICENSE-TEMPLATE, still a placeholder) for the",
"@conusai-kit packages, and under the Apache License 2.0 for the @conusai-sdk packages.",
"",
...conusai.map((component) => ` ${labelOf(component)} (${licenceOf(component)})`),
"",
rule,
`2. Runtime (${runtime.length})`,
rule,
"",
...runtime.map(
(component) =>
` ${labelOf(component)} (${licenceOf(component)}); the licence text is /usr/local/LICENSE in the image`,
),
"",
rule,
`3. npm packages (${npm.length})`,
rule,
"",
]
// One group per licence text. A package that ships no licence file is grouped by what it declares.
const groups = new Map()
for (const component of npm) {
const text = textOf(component)
const declared = licenceOf(component)
const key = text === null ? `declared:${declared}` : `text:${sha(text)}`
if (!groups.has(key)) groups.set(key, { declared, text, members: [] })
groups.get(key).members.push(component)
}
const ordered = [...groups.values()].sort(
(a, b) => a.declared.localeCompare(b.declared) || labelOf(a.members[0]).localeCompare(labelOf(b.members[0])),
)
for (const group of ordered) {
lines.push(`Licence: ${group.declared}`, "Packages:")
for (const component of group.members) lines.push(` ${labelOf(component)}`)
lines.push("")
if (group.text === null) {
lines.push(` (these packages ship no licence file; they declare ${group.declared} in their package.json)`)
} else {
// Tabs are layout only in licence files. Expand them before adding our two-space prefix so
// generated notices stay readable and pass whitespace checks without changing licence text.
lines.push(...group.text.split("\n").map((line) => (line === "" ? "" : ` ${line.replaceAll("\t", " ")}`)))
}
lines.push("", "-".repeat(78), "")
}
lines.push(
rule,
`4. Operating system packages of the base image (${os.length})`,
rule,
"",
"Each package's licence is in its copyright file in the image, /usr/share/doc/<package>/copyright.",
"",
...os.map((component) => ` ${component.name} ${component.version}`),
"",
)
return lines.join("\n")
}
/**
* The components of an SBOM that a notices text does not list, by label. By name alone when `byName`
* is set: a version that moved since the file was written is drift to refresh, not a component missing.
*/
export function unlisted(document, text, { byName = false } = {}) {
return (document.components ?? [])
.filter((component) => {
if (property(component, "conusai:layer") === "os") {
return !text.includes(byName ? ` ${component.name} ` : ` ${component.name} ${component.version}\n`)
}
return !text.includes(byName ? ` ${nameOf(component)}@` : ` ${labelOf(component)}`)
})
.map(labelOf)
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
const args = process.argv.slice(2)
const flags = new Set(args.filter((arg) => arg.startsWith("--") && arg !== "--out"))
const outAt = args.indexOf("--out")
const out = outAt === -1 ? "THIRD-PARTY-NOTICES" : args[outAt + 1]
const input = args.find((arg, at) => !arg.startsWith("--") && (outAt === -1 || at !== outAt + 1))
if (!input) {
console.error("usage: node scripts/third-party-notices.mjs <sbom.cdx.json> [--check | --complete] [--out FILE]")
process.exit(2)
}
const document = JSON.parse(readFileSync(input, "utf8"))
const text = notices(document)
if (flags.has("--check") || flags.has("--complete")) {
const present = existsSync(out) ? readFileSync(out, "utf8") : ""
const missing = unlisted(document, present, { byName: flags.has("--complete") })
for (const label of missing) console.error(`third-party-notices: ${out} does not list ${label}`)
if (flags.has("--check") && present !== text && missing.length === 0) {
console.error(`third-party-notices: ${out} is not what the SBOM generates`)
}
process.exit(missing.length === 0 && (flags.has("--complete") || present === text) ? 0 : 1)
}
writeFileSync(out, text)
console.log(`third-party-notices: wrote ${out} (${document.components?.length ?? 0} components)`)
}