UNPKG

rollup-plugin-sbom

Version:

A rollup, rolldown and vite plugin to generate SBOMs for your application

494 lines (493 loc) 20.3 kB
import { createRequire } from "node:module"; import path, { dirname, join } from "node:path"; import spdxExpressionParse from "spdx-expression-parse"; import * as CDX from "@cyclonedx/cyclonedx-library"; import { Enums, Spec } from "@cyclonedx/cyclonedx-library"; import fs from "node:fs/promises"; import normalizePackageData from "normalize-package-data"; import { PackageURL, PurlQualifierNames } from "packageurl-js"; import { Utils } from "@cyclonedx/cyclonedx-library/Contrib/FromNodePackageJson"; //#region src/helpers.ts /** * Plugin identifier for {@link rollupPluginSbom} */ const PLUGIN_ID = "rollup-plugin-sbom"; /** * Returns the folder path from a module id * @param moduleId The module id * @returns The path to the imported module */ function getModulePathFromModuleId(moduleId) { return dirname(moduleId); } /** * Generate a package ID from a package object * @param pkg The package object * @returns A package ID */ function generatePackageId(pkg) { return `${pkg.name}@${pkg.version}`; } /** * CycloneDX requires the use of their models and repositories, but we want to provide * easy usage for the developers so we need to convert our simple interface to the corresponding models * @param {OrganizationalEntityOption} option The option to convert * @returns A CycloneDX {@link CDX.Models.OrganizationEntity} */ function convertOrganizationalEntityOptionToModel(option) { return new CDX.Models.OrganizationalEntity({ name: option.name, url: new Set(option.url), contact: new CDX.Models.OrganizationalContactRepository(option.contact.map((contact) => new CDX.Models.OrganizationalContact(contact))) }); } //#endregion //#region src/analyzer.ts /** * Filter out virtual modules and non-node_modules * @param value The module ID to filter * @returns True if the module ID is a valid external module, false otherwise * @see https://rollupjs.org/plugin-development/#conventions */ function filterExternalModuleId(value) { if (value.startsWith("\0") || value.startsWith("virtual:")) return false; if (value.includes("node_modules")) return true; return false; } async function resolveExternalModule(context, moduleId, parentModuleId, transitiveResolveLimit, isTransitive = false) { if (transitiveResolveLimit === 0) return null; const moduleInfo = context.getModuleInfo(moduleId); const dependsOnModuleIds = [...moduleInfo?.importedIds ?? [], ...moduleInfo?.dynamicallyImportedIds ?? []].filter(filterExternalModuleId); return { moduleId, parentModuleId, moduleInfo, modulePath: getModulePathFromModuleId(moduleId), isTransitive, dependsOn: await Promise.all(dependsOnModuleIds.map((id) => resolveExternalModule(context, id, moduleId, transitiveResolveLimit - 1, true))).then((allModuleIdsOrNull) => allModuleIdsOrNull.filter(Boolean)) }; } async function getAllExternalModules(context, bundle, transitiveResolveLimit = 2) { const allModules = /* @__PURE__ */ new Set(); for (const [id, module] of Object.entries(bundle)) { if (module.type === "asset") { context.debug({ message: `Skipping asset "${id}"`, meta: { moduleId: id, module } }); continue; } const importedUniqueModuleIds = /* @__PURE__ */ new Set([ ...module.moduleIds, ...module.imports, ...module.dynamicImports ]); context.debug({ message: `Analyzing generated chunk "${id}" (${importedUniqueModuleIds.size} imported ids)`, meta: { moduleId: id, module } }); const externalModulesWithinBundle = await Promise.all([...importedUniqueModuleIds].filter(filterExternalModuleId).map((moduleId) => resolveExternalModule(context, moduleId, id, transitiveResolveLimit))).then((allModules) => allModules.filter(Boolean)); context.debug({ message: `Found ${externalModulesWithinBundle.length} external entries within "${id}"`, meta: { moduleId: id, modules: externalModulesWithinBundle } }); externalModulesWithinBundle.forEach(allModules.add, allModules); } context.debug({ message: `Aggregated ${allModules.size} unique external entries across all chunks`, meta: { allModules } }); return allModules; } //#endregion //#region src/package-reader.ts /** * Read a normalized package.json as object from a directory or file path * @param {string} dirOrFilePath The directory or package path to use * @returns A normalized package.json object */ async function readPackage(dirOrFilePath) { const packagePath = dirOrFilePath.endsWith(`${path.sep}package.json`) ? path.resolve(dirOrFilePath) : path.resolve(dirOrFilePath, "package.json"); return parsePackage(await fs.readFile(packagePath, "utf8")); } /** * Parses a JSON string of package.json format into a normalized package object via {@link normalizePackageData} * @param {string} packageFile The package.json file content * @returns A normalized package.json object */ function parsePackage(packageFile) { if (typeof packageFile !== "string") throw new TypeError(`packageFile should be a string (received ${typeof packageFile}).`); const pkg = JSON.parse(packageFile); normalizePackageData(pkg, null, false); return pkg; } //#endregion //#region src/package-finder.ts /** * Searches up the directory tree to find a valid package.json. * The search is stopped if a '.git' directory is found, marking the project root. * @param {PluginContext} context The rollup plugin context * @param {string} startDir The directory to start searching from. * @returns {Promise<PackageFinderResult | null>} The package path and normalized package.json, or null. */ async function findValidPackageJson(context, startDir) { let currentDir = startDir; while (path.dirname(currentDir) !== currentDir) { const pkgPath = path.join(currentDir, "package.json"); try { if (!(await fs.stat(pkgPath)).isFile()) { currentDir = path.dirname(currentDir); continue; } const pkg = await readPackage(pkgPath); if (pkg.name && pkg.version) return { path: path.dirname(pkgPath), package: pkg }; } catch {} try { if ((await fs.stat(path.join(currentDir, ".git"))).isDirectory()) { context.warn(`Package finder did not find any result and reached the git directory while resolving ${startDir}`); break; } } catch {} currentDir = path.dirname(currentDir); } return null; } //#endregion //#region src/license-evidence.ts function* getLicenseEvidence(context, packageDir, licenseEvidenceGatherer) { try { const files = licenseEvidenceGatherer.getFileAttachments(packageDir, (error) => { context.debug(`Collecting license attachments in ${packageDir} failed: ${error instanceof Error ? error.message : String(error)}`); }) || []; for (const { file, text } of files) yield new CDX.Models.NamedLicense(`file: ${file}`, { text }); } catch (error) { context.warn(`Collecting license evidence in ${packageDir} failed: ${error instanceof Error ? error.message : error}`); } } //#endregion //#region src/dependency-info-registry.ts /** * Creates a new dependency info registry */ function createDependencyInfoRegistry() { return /* @__PURE__ */ new Map(); } /** * Find the corresponding package.json based on a module path * @param {PluginContext} context The rollup plugin context * @param {DependencyInfoRegistry} registry The package registry where the package should be stored * @param {ModulePathString} modulePath The module path * @param {CDX.Utils.LicenseUtility.LicenseEvidenceGatherer} licenseEvidenceGatherer License evidence gatherer; will collect license evidence if set * @returns A normalized dependency info object or null (if not found / virtual module) */ async function aggregateDependencyInfoByModulePath(context, registry, modulePath, licenseEvidenceGatherer) { if (registry.has(modulePath)) return registry.get(modulePath) ?? null; if (!filterExternalModuleId(modulePath)) return null; const dependencyPackage = await findValidPackageJson(context, modulePath); if (!dependencyPackage) return null; const licenseEvidenceList = licenseEvidenceGatherer ? Array.from(getLicenseEvidence(context, dependencyPackage.path, licenseEvidenceGatherer)) : []; const info = { path: dependencyPackage.path, pkg: dependencyPackage.package, licenseEvidence: licenseEvidenceList }; registry.set(modulePath, info); return info; } /** * Find the closest package.json based on a module identifier, uses {@link aggregateDependencyInfoByModulePath} internally. * @param {PluginContext} context The rollup plugin context * @param {DependencyInfoRegistry} registry The package registry where the package should be stored * @param {ModuleIdString} moduleId The module id base * @param {CDX.Contrib.License.Utils.LicenseEvidenceGatherer} licenseEvidenceGatherer License evidence gatherer; will collect license evidence if set * @returns A normalized dependency info object or null (if not found / virtual module) */ async function aggregateDependencyInfoByModuleId(context, registry, moduleId, licenseEvidenceGatherer) { return aggregateDependencyInfoByModulePath(context, registry, getModulePathFromModuleId(moduleId), licenseEvidenceGatherer); } //#endregion //#region src/tools.ts /** * A list of package names which will be looked up within the project * and push them to the tools list within the SBOM. */ const knownTools = [ "rollup-plugin-sbom", "vite", "rollup", "rolldown" ]; /** * Automatically register common tools related to the build process on a BOM model * * @since 1.0.0 * @param {PluginContext} context The rollup plugin context * @param {CDX.Models.Bom} bom The root BOM to attach tools to * @param {CDX.Contrib.FromNodePackageJson.Builders.ToolBuilder} builder The CDX tool builder instance * @param {CDX.Contrib.License.Utils.LicenseEvidenceGatherer} [licenseEvidenceGatherer] Optional: enable license evidence gathering */ async function autoRegisterTools(context, bom, builder, licenseEvidenceGatherer) { const toolPackageRegistry = createDependencyInfoRegistry(); const projectRequire = createRequire(join(process.cwd(), "package.json")); async function registerTool(packageName) { try { const toolModulePath = projectRequire.resolve(packageName); const dependencyInfo = await aggregateDependencyInfoByModulePath(context, toolPackageRegistry, toolModulePath, licenseEvidenceGatherer); if (dependencyInfo && dependencyInfo.pkg) { const tool = builder.makeTool(dependencyInfo.pkg); if (tool) { context.info({ message: `Registering tool "${tool?.name}" in SBOM`, meta: { dependencyInfo } }); bom.metadata.tools.tools.add(tool); } } } catch (error) { context.warn(`Error during auto-registration of tool "${packageName}": ${error}`); } } for (const pkgName of knownTools) { context.debug(`Trying to autoregister tool "${pkgName}"`); await registerTool(pkgName); } } //#endregion //#region src/options.ts const DEFAULT_OPTIONS = { specVersion: Spec.Version.v1dot7, rootComponentType: Enums.ComponentType.Application, outDir: "cyclonedx", outFilename: "bom", outFormats: ["json"], saveTimestamp: true, autodetect: true, generateSerial: false, includeWellKnown: true, supplier: void 0, properties: void 0, collectLicenseEvidence: false, beforeCollect: void 0, afterCollect: void 0 }; //#endregion //#region src/purl.ts /** * Compose PURLs from a normalized package.json declaration. * @param {NormalizedPackageJson} packageJson The normalized package data * @see https://github.com/CycloneDX/cyclonedx-webpack-plugin/blob/master/src/factories.ts * @see https://github.com/CycloneDX/cyclonedx-javascript-library/releases/tag/v10.0.0 */ function composePackageUrlFromPackageJson(packageJson) { let name = packageJson.name; let namespace = void 0; if (name.startsWith("@")) { const nameParts = name.split("/"); namespace = nameParts.shift(); name = nameParts.join("/"); } const qualifiers = {}; const { tarball } = packageJson.dist ?? {}; if (typeof tarball === "string" && tarball.length > 5) { if (!Utils.defaultRegistryMatcher.test(tarball)) qualifiers[PurlQualifierNames.DownloadUrl] = tarball; } else if (typeof packageJson.repository === "object") try { const url = new URL(packageJson.repository.url); const subdir = packageJson.repository.directory; if (typeof subdir === "string") url.hash = subdir; qualifiers[PurlQualifierNames.VcsUrl] = url.toString(); } catch {} try { return new PackageURL("npm", namespace, name, packageJson.version, qualifiers, void 0); } catch { return; } } //#endregion //#region src/index.ts /** * Plugin to generate CycloneDX SBOMs for your application or library * Compatible with Rollup and Vite. */ function rollupPluginSbom(userOptions) { const options = { ...DEFAULT_OPTIONS, ...userOptions }; let bom; let dependencyInfoRegistry; let registeredModules; let rootComponent; let rootPackageJson; const cdxExternalReferenceFactory = new CDX.Contrib.FromNodePackageJson.Factories.ExternalReferenceFactory(); const cdxLicenseFactory = new CDX.Contrib.License.Factories.LicenseFactory(spdxExpressionParse); const cdxToolBuilder = new CDX.Contrib.FromNodePackageJson.Builders.ToolBuilder(cdxExternalReferenceFactory); const cdxLicenseEvidenceGatherer = new CDX.Contrib.License.Utils.LicenseEvidenceGatherer(); const cdxComponentBuilder = new CDX.Contrib.FromNodePackageJson.Builders.ComponentBuilder(cdxExternalReferenceFactory, cdxLicenseFactory); const jsonSerializer = new CDX.Serialize.JsonSerializer(new CDX.Serialize.JSON.Normalize.Factory(CDX.Spec.SpecVersionDict[options.specVersion])); const xmlSerializer = new CDX.Serialize.XmlSerializer(new CDX.Serialize.XML.Normalize.Factory(CDX.Spec.SpecVersionDict[options.specVersion])); function processExternalModuleForBom(context, mod) { const dependencyInfo = dependencyInfoRegistry.get(mod.modulePath); if (!dependencyInfo) (mod.isTransitive ? context.debug : context.warn)({ message: `Missing dependency info for module ${mod.modulePath} in registry, this should not happen (ID: ${mod.moduleId})`, meta: mod }); const { pkg, licenseEvidence } = dependencyInfo || {}; if (!pkg || !pkg.name || !pkg.version) { (mod.isTransitive ? context.debug : context.warn)({ message: `Missing package data for module ${mod.modulePath} in registry, this should not happen (ID: ${mod.moduleId})`, meta: mod }); return; } const packageId = generatePackageId(pkg); const doesComponentExist = registeredModules.has(packageId); const component = registeredModules.get(packageId) ?? cdxComponentBuilder.makeComponent(pkg); if (!component) { context.warn(`Failed to create component for ${pkg.name}@${pkg.version}`); return; } if (!doesComponentExist) { context.debug({ message: `Registering package ${pkg?.name}@${pkg?.version}`, meta: mod }); const componentPurl = composePackageUrlFromPackageJson(pkg); if (componentPurl) { component.purl = componentPurl.toString(); component.bomRef.value = componentPurl.toString(); } else context.warn(`Failed to compose package URL for ${pkg.name}@${pkg.version}`); component.licenses.forEach((l) => { l.acknowledgement = CDX.Enums.LicenseAcknowledgement.Declared; }); if (options.collectLicenseEvidence && Array.isArray(licenseEvidence) && licenseEvidence.length > 0) { component.evidence = new CDX.Models.ComponentEvidence({ licenses: new CDX.Models.LicenseRepository(licenseEvidence) }); context.debug({ message: `Attaching ${component.evidence.licenses.size} license evidence to ${pkg?.name}@${pkg?.version}`, meta: component.evidence }); } registeredModules.set(packageId, component); bom.components.add(component); if (rootPackageJson?.dependencies && pkg.name in rootPackageJson.dependencies) rootComponent?.dependencies.add(component.bomRef); } mod.dependsOn.forEach((externalDependencyModuleInfo) => { const dependencyComponent = processExternalModuleForBom(context, externalDependencyModuleInfo); if (dependencyComponent) component.dependencies.add(dependencyComponent.bomRef); else context.debug(`Skipped adding dependency for ${externalDependencyModuleInfo.modulePath}: component unavailable`); }); return component; } return { name: PLUGIN_ID, async buildStart() { bom = new CDX.Models.Bom({ metadata: new CDX.Models.Metadata({ supplier: options.supplier && convertOrganizationalEntityOptionToModel(options.supplier), properties: options.properties && new CDX.Models.PropertyRepository(options.properties.map(({ name, value }) => new CDX.Models.Property(name, value))) }) }); dependencyInfoRegistry = createDependencyInfoRegistry(); registeredModules = /* @__PURE__ */ new Map(); rootComponent = void 0; rootPackageJson = void 0; if (options.autodetect) try { this.debug(`Autodetection enabled, trying to resolve root component`); const rootPkg = await readPackage(process.cwd()); if (rootPkg) { this.info(`Detected root ${rootPkg.name} v${rootPkg.version}`); rootPackageJson = rootPkg; rootComponent = cdxComponentBuilder.makeComponent(rootPkg, options.rootComponentType); rootComponent.version = rootPkg.version; const rootComponentPurl = composePackageUrlFromPackageJson(rootPkg); if (rootComponentPurl) { rootComponent.purl = rootComponentPurl.toString(); rootComponent.bomRef.value = rootComponentPurl.toString(); } else this.warn(`Failed to compose package URL for ${rootPkg.name}@${rootPkg.version}`); bom.metadata.component = rootComponent; } } catch (err) { this.error({ message: `autodetection failed: ${err instanceof Error ? err.message : err}`, meta: { error: err } }); } bom.metadata.lifecycles.add(CDX.Enums.LifecyclePhase.Build); if (options.saveTimestamp) { this.info(`Saving timestamp to SBOM`); bom.metadata.timestamp = /* @__PURE__ */ new Date(); } if (options.generateSerial) { this.info(`Generating random serial number for SBOM`); bom.serialNumber = CDX.Contrib.Bom.Utils.randomSerialNumber(); } await autoRegisterTools(this, bom, cdxToolBuilder, options.collectLicenseEvidence ? cdxLicenseEvidenceGatherer : void 0); if (options.beforeCollect) { this.debug("Applying custom transform \"beforeCollect\""); options.beforeCollect(bom); } }, /** * We use this hook to load normalized package.json data and module specific info for each imported module. * As this hook runs in parallel before finishing the bundle, we can ensure that * all required package.json files are loaded before we start the BOM generation. */ async moduleParsed(moduleInfo) { await aggregateDependencyInfoByModuleId(this, dependencyInfoRegistry, moduleInfo.id, options.collectLicenseEvidence ? cdxLicenseEvidenceGatherer : void 0); }, /** * Build the SBOM and emit files */ async generateBundle(_outputOptions, bundle) { const tree = await getAllExternalModules(this, bundle); for (const mod of tree) if (!dependencyInfoRegistry.has(mod.modulePath)) await aggregateDependencyInfoByModulePath(this, dependencyInfoRegistry, mod.modulePath, options.collectLicenseEvidence ? cdxLicenseEvidenceGatherer : void 0); for (const mod of tree) processExternalModuleForBom(this, mod); const formatMap = { json: jsonSerializer, xml: xmlSerializer }; if (options.afterCollect) { this.debug("Applying custom transform \"afterCollect\""); options.afterCollect(bom); } options.outFormats.forEach((format) => { if (!formatMap[format]) throw new Error(`Unsupported format: ${format}`); const sbomFilePath = join(options.outDir, `${options.outFilename}.${format}`); this.debug(`Emitting SBOM asset to ${sbomFilePath}`); this.emitFile({ type: "asset", fileName: sbomFilePath, needsCodeReference: false, source: formatMap[format].serialize(bom, { sortLists: false, space: " " }) }); }); if (options.includeWellKnown) { this.debug(`Emitting well-known file to .well-known/sbom`); this.emitFile({ type: "asset", fileName: ".well-known/sbom", needsCodeReference: false, source: jsonSerializer.serialize(bom, { sortLists: false, space: " " }) }); } } }; } //#endregion export { rollupPluginSbom as default }; //# sourceMappingURL=index.mjs.map