200 lines
8.3 KiB
TypeScript
200 lines
8.3 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
|
//
|
|
// The software bill of materials of the built image and the THIRD-PARTY-NOTICES generated from it
|
|
// (plan item AT5.1): the SBOM reads what is installed in a pnpm store (a runtime dependency and not a
|
|
// link to one, once per version), and the notices list every component of it, with each licence text
|
|
// printed once however many packages share it. The committed THIRD-PARTY-NOTICES is checked against
|
|
// the dependencies this template declares.
|
|
|
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"
|
|
import { tmpdir } from "node:os"
|
|
import { dirname, join } from "node:path"
|
|
import { afterEach, describe, expect, it } from "vitest"
|
|
import { dpkgComponents, npmComponents, sbom } from "../scripts/sbom.mjs"
|
|
import { notices, unlisted } from "../scripts/third-party-notices.mjs"
|
|
|
|
const APP_ROOT = new URL("..", import.meta.url).pathname
|
|
|
|
const made: string[] = []
|
|
afterEach(() => {
|
|
for (const dir of made.splice(0)) rmSync(dir, { recursive: true, force: true })
|
|
})
|
|
|
|
const MIT = "MIT License\n\nCopyright (c) 2020 Someone\n\nPermission is hereby granted, free of charge, ..."
|
|
|
|
/** A pnpm virtual store: each entry holds its own package and links to the packages it depends on. */
|
|
function image(): string {
|
|
const root = mkdtempSync(join(tmpdir(), "sbom-"))
|
|
made.push(root)
|
|
const put = (path: string, text: string) => {
|
|
mkdirSync(dirname(join(root, path)), { recursive: true })
|
|
writeFileSync(join(root, path), text)
|
|
}
|
|
const store = "node_modules/.pnpm"
|
|
put("package.json", JSON.stringify({ name: "demo-app", version: "1.2.3" }))
|
|
put(
|
|
`${store}/alpha@1.0.0/node_modules/alpha/package.json`,
|
|
JSON.stringify({ name: "alpha", version: "1.0.0", license: "MIT" }),
|
|
)
|
|
put(`${store}/alpha@1.0.0/node_modules/alpha/LICENSE`, MIT)
|
|
put(
|
|
`${store}/beta@2.0.0/node_modules/beta/package.json`,
|
|
JSON.stringify({ name: "beta", version: "2.0.0", license: "MIT" }),
|
|
)
|
|
put(`${store}/beta@2.0.0/node_modules/beta/LICENSE.md`, MIT)
|
|
// the same version under two peer sets is one component
|
|
put(
|
|
`${store}/beta@2.0.0_react@19/node_modules/beta/package.json`,
|
|
JSON.stringify({ name: "beta", version: "2.0.0", license: "MIT" }),
|
|
)
|
|
put(
|
|
`${store}/@scope+gamma@3.0.0/node_modules/@scope/gamma/package.json`,
|
|
JSON.stringify({
|
|
name: "@scope/gamma",
|
|
version: "3.0.0",
|
|
license: { type: "ISC" },
|
|
repository: { url: "git+https://example.test/gamma.git" },
|
|
}),
|
|
)
|
|
put(
|
|
`${store}/@conusai-kit+core@0.1.0/node_modules/@conusai-kit/core/package.json`,
|
|
JSON.stringify({ name: "@conusai-kit/core", version: "0.1.0", license: "LicenseRef-ConusAI-Template-1.0" }),
|
|
)
|
|
put(
|
|
`${store}/delta@4.0.0/node_modules/delta/package.json`,
|
|
JSON.stringify({ name: "delta", version: "4.0.0", license: "(MIT OR Apache-2.0)" }),
|
|
)
|
|
// alpha depends on beta: a link inside alpha's entry, which is not a second copy of beta
|
|
symlinkSync(join(root, `${store}/beta@2.0.0/node_modules/beta`), join(root, `${store}/alpha@1.0.0/node_modules/beta`))
|
|
return root
|
|
}
|
|
|
|
describe("the SBOM of an image", () => {
|
|
it("holds each installed package once, with its licence and the text of its licence file", () => {
|
|
const components = npmComponents(image())
|
|
expect(components.map((component) => component["bom-ref"])).toEqual([
|
|
"pkg:npm/%40conusai-kit/core@0.1.0",
|
|
"pkg:npm/%40scope/gamma@3.0.0",
|
|
"pkg:npm/alpha@1.0.0",
|
|
"pkg:npm/beta@2.0.0",
|
|
"pkg:npm/delta@4.0.0",
|
|
])
|
|
const alpha = components.find((component) => component.name === "alpha")
|
|
expect(alpha?.licenses[0]?.license).toMatchObject({ id: "MIT", text: { content: MIT } })
|
|
expect(components.find((component) => component.name === "gamma")).toMatchObject({
|
|
group: "@scope",
|
|
licenses: [{ license: { id: "ISC" } }],
|
|
externalReferences: [{ type: "vcs", url: "https://example.test/gamma" }],
|
|
})
|
|
expect(components.find((component) => component.name === "delta")?.licenses).toEqual([
|
|
{ expression: "(MIT OR Apache-2.0)" },
|
|
])
|
|
})
|
|
|
|
it("marks the ConusAI packages, whose licence is LICENSE-TEMPLATE, and names their licence as a reference", () => {
|
|
const core = npmComponents(image()).find((component) => component.name === "core")
|
|
expect(core?.properties).toContainEqual({ name: "conusai:component", value: "true" })
|
|
expect(core?.licenses[0]?.license).toEqual({ name: "LicenseRef-ConusAI-Template-1.0" })
|
|
})
|
|
|
|
it("is a CycloneDX 1.5 document with the application, the Node.js runtime and the packages", () => {
|
|
const document = sbom({ root: image(), dpkg: "/nonexistent", nodeVersion: "v24.1.0" })
|
|
expect(document).toMatchObject({
|
|
bomFormat: "CycloneDX",
|
|
specVersion: "1.5",
|
|
metadata: { component: { type: "application", name: "app", version: "0.0.0" } },
|
|
})
|
|
expect(document.components[0]).toMatchObject({ type: "platform", name: "node", version: "24.1.0" })
|
|
expect(document.components).toHaveLength(6)
|
|
})
|
|
|
|
it("reads the operating system packages that are installed, and no others", () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "dpkg-"))
|
|
made.push(dir)
|
|
const status = join(dir, "status")
|
|
writeFileSync(
|
|
status,
|
|
[
|
|
"Package: libc6\nStatus: install ok installed\nVersion: 2.41-1\nArchitecture: amd64",
|
|
"Package: gone\nStatus: deinstall ok config-files\nVersion: 1.0",
|
|
"Package: zlib1g\nStatus: install ok installed\nVersion: 1:1.3.dfsg-1",
|
|
"",
|
|
].join("\n\n"),
|
|
)
|
|
const components = dpkgComponents(status, dir)
|
|
expect(components.map((component) => `${component.name} ${component.version}`)).toEqual([
|
|
"libc6 2.41-1",
|
|
"zlib1g 1:1.3.dfsg-1",
|
|
])
|
|
})
|
|
})
|
|
|
|
describe("THIRD-PARTY-NOTICES generated from an SBOM", () => {
|
|
const document = () =>
|
|
sbom({ root: image(), dpkg: "/nonexistent", name: "demo-app", version: "1.2.3", nodeVersion: "v24.1.0" })
|
|
|
|
it("lists every component, and prints a licence text shared by several packages once", () => {
|
|
const text = notices(document())
|
|
expect(unlisted(document(), text)).toEqual([])
|
|
expect(text).toContain("@conusai-kit/core@0.1.0")
|
|
expect(text).toContain("node@24.1.0")
|
|
expect(text.split("Permission is hereby granted").length - 1).toBe(1)
|
|
expect(text).toContain("alpha@1.0.0")
|
|
expect(text).toContain("beta@2.0.0")
|
|
expect(text).toContain("these packages ship no licence file; they declare ISC")
|
|
})
|
|
|
|
it("is the same text every time it is generated from the same SBOM", () => {
|
|
expect(notices(document())).toBe(notices(document()))
|
|
})
|
|
|
|
it("is checked against the SBOM: a component it does not list is named", () => {
|
|
const text = notices(document())
|
|
const more = document()
|
|
more.components.push({
|
|
type: "library",
|
|
"bom-ref": "pkg:npm/epsilon@5.0.0",
|
|
name: "epsilon",
|
|
version: "5.0.0",
|
|
purl: "pkg:npm/epsilon@5.0.0",
|
|
licenses: [],
|
|
})
|
|
expect(unlisted(more, text)).toEqual(["epsilon@5.0.0"])
|
|
// a version that moved is drift by name, not a missing component
|
|
const moved = document()
|
|
moved.components = moved.components.map((component) =>
|
|
component.name === "alpha" ? { ...component, version: "1.0.1" } : component,
|
|
)
|
|
expect(unlisted(moved, text)).toEqual(["alpha@1.0.1"])
|
|
expect(unlisted(moved, text, { byName: true })).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe("the committed THIRD-PARTY-NOTICES", () => {
|
|
const text = readFileSync(join(APP_ROOT, "THIRD-PARTY-NOTICES"), "utf8")
|
|
|
|
it("says what it is generated from, and that it is not edited by hand", () => {
|
|
expect(text).toContain("software bill of materials of the built image")
|
|
expect(text).toContain("do not edit it by hand")
|
|
})
|
|
|
|
it("lists every runtime dependency this template declares, by name", () => {
|
|
const declared = new Set<string>()
|
|
for (const path of [
|
|
"package.json",
|
|
"apps/web/package.json",
|
|
"apps/worker/package.json",
|
|
"config/package.json",
|
|
"modules/package.json",
|
|
]) {
|
|
const manifest = JSON.parse(readFileSync(join(APP_ROOT, path), "utf8")) as {
|
|
dependencies?: Record<string, string>
|
|
}
|
|
for (const name of Object.keys(manifest.dependencies ?? {})) if (!name.startsWith("@app/")) declared.add(name)
|
|
}
|
|
expect(declared.size).toBeGreaterThan(15)
|
|
const missing = [...declared].filter((name) => !text.includes(` ${name}@`))
|
|
expect(missing).toEqual([])
|
|
})
|
|
})
|