import assert from "node:assert/strict"; import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const lockPath = path.join(root, "web/package-lock.json"); const outputPath = path.join(root, "docs/web/sbom.spdx.json"); const lockBytes = fs.readFileSync(lockPath); const lock = JSON.parse(lockBytes); const noticesBytes = fs.readFileSync(path.join(root, "docs/web/third-party-notices.json")); const notices = JSON.parse(noticesBytes); const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"); const spdxId = (value) => `SPDXRef-${value.replace(/[^A-Za-z0-9.-]/g, "-")}-${sha256(value).slice(0, 12)}`; const packages = []; const rootId = "SPDXRef-Package-blender-web-editor"; packages.push({ SPDXID: rootId, name: lock.name, versionInfo: lock.version, downloadLocation: "NOASSERTION", filesAnalyzed: false, licenseConcluded: "NOASSERTION", licenseDeclared: "NOASSERTION", copyrightText: "NOASSERTION", checksums: [{ algorithm: "SHA256", checksumValue: sha256(lockBytes) }] }); for (const [packagePath, entry] of Object.entries(lock.packages).sort(([left], [right]) => left.localeCompare(right))) { if (!packagePath || !entry.version) continue; const marker = packagePath.lastIndexOf("node_modules/"); const name = entry.name ?? packagePath.slice(marker + "node_modules/".length); const item = { SPDXID: spdxId(`npm-${packagePath}`), name, versionInfo: entry.version, downloadLocation: entry.resolved ?? "NOASSERTION", filesAnalyzed: false, licenseConcluded: "NOASSERTION", licenseDeclared: "NOASSERTION", copyrightText: "NOASSERTION", externalRefs: [{ referenceCategory: "PACKAGE-MANAGER", referenceType: "purl", referenceLocator: `pkg:npm/${encodeURIComponent(name)}@${entry.version}` }] }; if (typeof entry.integrity === "string" && entry.integrity.startsWith("sha512-")) item.checksums = [{ algorithm: "SHA512", checksumValue: Buffer.from(entry.integrity.slice(7), "base64").toString("hex") }]; packages.push(item); } for (const notice of notices.packages.filter((item) => !item.source.includes("node_modules"))) { packages.push({ SPDXID: spdxId(`vendored-${notice.name}-${notice.version}`), name: notice.name, versionInfo: notice.version, downloadLocation: "NOASSERTION", filesAnalyzed: false, licenseConcluded: "NOASSERTION", licenseDeclared: notice.license, copyrightText: "NOASSERTION", sourceInfo: notice.source }); } packages.sort((left, right) => left.SPDXID.localeCompare(right.SPDXID)); const document = { spdxVersion: "SPDX-2.3", dataLicense: "CC0-1.0", SPDXID: "SPDXRef-DOCUMENT", name: "blender-web-editor-sbom", documentNamespace: `https://blender-web.local/spdx/${sha256(Buffer.concat([lockBytes, noticesBytes]))}`, creationInfo: { created: "1970-01-01T00:00:00Z", creators: ["Tool: tools/web/generate-sbom.mjs"] }, documentDescribes: [rootId], packages, relationships: packages.filter((item) => item.SPDXID !== rootId).map((item) => ({ spdxElementId: rootId, relationshipType: "DEPENDS_ON", relatedSpdxElement: item.SPDXID })), }; fs.writeFileSync(outputPath, `${JSON.stringify(document, null, 2)}\n`); assert.equal(new Set(packages.map((item) => item.SPDXID)).size, packages.length); assert.ok(packages.length >= Object.keys(lock.packages).length); process.stdout.write(`sbom-ok packages=${packages.length} sha256=${sha256(fs.readFileSync(outputPath))}\n`);