// 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 [--out THIRD-PARTY-NOTICES] // node scripts/third-party-notices.mjs --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 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//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 [--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)`) }