UNPKG

rollup-plugin-sbom

Version:

A rollup and vite plugin to generate SBOMs for your application

458 lines (443 loc) 16.8 kB
'use strict'; const path = require('node:path'); const CDX = require('@cyclonedx/cyclonedx-library'); const node_module = require('node:module'); const fs = require('node:fs/promises'); const normalizePackageData = require('normalize-package-data'); var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null; function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; } function _interopNamespaceCompat(e) { if (e && typeof e === 'object' && 'default' in e) return e; const n = Object.create(null); if (e) { for (const k in e) { n[k] = e[k]; } } n.default = e; return n; } const path__default = /*#__PURE__*/_interopDefaultCompat(path); const CDX__namespace = /*#__PURE__*/_interopNamespaceCompat(CDX); const fs__default = /*#__PURE__*/_interopDefaultCompat(fs); const normalizePackageData__default = /*#__PURE__*/_interopDefaultCompat(normalizePackageData); const PLUGIN_ID = "rollup-plugin-sbom"; function getModulePathFromModuleId(moduleId) { return path.dirname(moduleId); } function generatePackageId(pkg) { return `${pkg.name}@${pkg.version}`; } function convertOrganizationalEntityOptionToModel(option) { return new CDX__namespace.Models.OrganizationalEntity({ name: option.name, url: new Set(option.url), contact: new CDX__namespace.Models.OrganizationalContactRepository( option.contact.map((contact) => new CDX__namespace.Models.OrganizationalContact(contact)) ) }); } function filterExternalModuleId(value) { if (value.startsWith("\0")) { return false; } if (value.includes("node_modules")) { return true; } return false; } async function resolveExternalModule(context, moduleId, parentModuleId, transitiveResolveLimit) { 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), dependsOn: await Promise.all( dependsOnModuleIds.map((id) => resolveExternalModule(context, id, moduleId, transitiveResolveLimit - 1)) ).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.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)) // resolve module information ).then((allModules2) => allModules2.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; } async function readPackage(dirOrFilePath) { const packagePath = dirOrFilePath.endsWith(`${path__default.sep}package.json`) ? path__default.resolve(dirOrFilePath) : path__default.resolve(dirOrFilePath, "package.json"); const packageFile = await fs__default.readFile(packagePath, "utf8"); return parsePackage(packageFile); } function parsePackage(packageFile) { if (typeof packageFile !== "string") { throw new TypeError(`packageFile should be a string (received ${typeof packageFile}).`); } const pkg = JSON.parse(packageFile); normalizePackageData__default(pkg, null, false); return pkg; } async function findValidPackageJson(context, startDir) { let currentDir = startDir; while (path__default.dirname(currentDir) !== currentDir) { const pkgPath = path__default.join(currentDir, "package.json"); try { const pkgJsonStat = await fs__default.stat(pkgPath); if (!pkgJsonStat.isFile()) { currentDir = path__default.dirname(currentDir); continue; } const pkg = await readPackage(pkgPath); if (pkg.name && pkg.version) { return { path: path__default.dirname(pkgPath), package: pkg }; } } catch { } try { const gitDirStat = await fs__default.stat(path__default.join(currentDir, ".git")); if (gitDirStat.isDirectory()) { context.warn( `Package finder did not find any result and reached the git directory while resolving ${startDir}` ); break; } } catch { } currentDir = path__default.dirname(currentDir); } return null; } 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__namespace.Models.NamedLicense(`file: ${file}`, { text }); } } catch (error) { context.warn( `Collecting license evidence in ${packageDir} failed: ${error instanceof Error ? error.message : error}` ); } return; } function createDependencyInfoRegistry() { return /* @__PURE__ */ new Map(); } 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; } async function aggregateDependencyInfoByModuleId(context, registry, moduleId, licenseEvidenceGatherer) { const modulePath = getModulePathFromModuleId(moduleId); return aggregateDependencyInfoByModulePath(context, registry, modulePath, licenseEvidenceGatherer); } const require$1 = node_module.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href))); const knownTools = ["rollup-plugin-sbom", "vite", "rollup"]; async function autoRegisterTools(context, bom, builder, licenseEvidenceGatherer) { const toolPackageRegistry = createDependencyInfoRegistry(); async function registerTool(packageName) { try { const toolModulePath = require$1.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}`); } } await Promise.all( knownTools.map(async (pkgName) => { context.debug(`Trying to autoregister tool "${pkgName}"`); await registerTool(pkgName); }) ); } const DEFAULT_OPTIONS = { specVersion: CDX.Spec.Version.v1dot6, rootComponentType: CDX.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 }; function rollupPluginSbom(userOptions) { const options = { ...DEFAULT_OPTIONS, ...userOptions }; const dependencyInfoRegistry = createDependencyInfoRegistry(); const cdxExternalReferenceFactory = new CDX__namespace.Factories.FromNodePackageJson.ExternalReferenceFactory(); const cdxLicenseFactory = new CDX__namespace.Factories.LicenseFactory(); const cdxPurlFactory = new CDX__namespace.Factories.FromNodePackageJson.PackageUrlFactory("npm"); const cdxToolBuilder = new CDX__namespace.Builders.FromNodePackageJson.ToolBuilder(cdxExternalReferenceFactory); const cdxLicenseEvidenceGatherer = new CDX__namespace.Utils.LicenseUtility.LicenseEvidenceGatherer(); const cdxComponentBuilder = new CDX__namespace.Builders.FromNodePackageJson.ComponentBuilder( cdxExternalReferenceFactory, cdxLicenseFactory ); const jsonSerializer = new CDX__namespace.Serialize.JsonSerializer( new CDX__namespace.Serialize.JSON.Normalize.Factory(CDX__namespace.Spec.SpecVersionDict[options.specVersion]) ); const xmlSerializer = new CDX__namespace.Serialize.XmlSerializer( new CDX__namespace.Serialize.XML.Normalize.Factory(CDX__namespace.Spec.SpecVersionDict[options.specVersion]) ); const metadata = new CDX__namespace.Models.Metadata({ supplier: options.supplier && convertOrganizationalEntityOptionToModel(options.supplier), properties: options.properties && new CDX__namespace.Models.PropertyRepository( options.properties.map(({ name, value }) => new CDX__namespace.Models.Property(name, value)) ) }); const bom = new CDX__namespace.Models.Bom({ metadata }); let rootComponent = void 0; let rootPackageJson = void 0; const registeredModules = /* @__PURE__ */ new Map(); function processExternalModuleForBom(context, mod) { const depedendencyInfo = dependencyInfoRegistry.get(mod.modulePath); if (!depedendencyInfo) { context.warn(`Missing dependency info for module ${mod.modulePath} in registry, this should not happen.`); } const { pkg, licenseEvidence } = depedendencyInfo; if (!pkg || !pkg.name || !pkg.version) { context.warn(`Missing package data for module ${mod.modulePath} in registry, this should not happen.`); return; } const packageId = generatePackageId(pkg); if (registeredModules.has(packageId)) { return registeredModules.get(packageId); } context.debug({ message: `Registering package ${pkg?.name}@${pkg?.version}`, meta: mod }); const component = cdxComponentBuilder.makeComponent(pkg); component.purl = cdxPurlFactory.makeFromComponent(component); component.bomRef.value = component.purl?.toString(); component.licenses.forEach((l) => { l.acknowledgement = CDX__namespace.Enums.LicenseAcknowledgement.Declared; }); if (options.collectLicenseEvidence && Array.isArray(licenseEvidence) && licenseEvidence.length > 0) { component.evidence = new CDX__namespace.Models.ComponentEvidence({ licenses: new CDX__namespace.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() { 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; rootComponent.purl = cdxPurlFactory.makeFromComponent(rootComponent); rootComponent.bomRef.value = rootComponent.purl?.toString(); 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__namespace.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__namespace.Utils.BomUtility.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) { 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 = path.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: " " }) }); } } }; } module.exports = rollupPluginSbom;