rollup-plugin-sbom
Version:
A rollup, rolldown and vite plugin to generate SBOMs for your application
520 lines (519 loc) • 22.6 kB
JavaScript
//#region \0rolldown/runtime.js
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
key = keys[i];
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
get: ((k) => from[k]).bind(null, key),
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
});
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
value: mod,
enumerable: true
}) : target, mod));
//#endregion
let node_path = require("node:path");
node_path = __toESM(node_path, 1);
let spdx_expression_parse = require("spdx-expression-parse");
spdx_expression_parse = __toESM(spdx_expression_parse, 1);
let _cyclonedx_cyclonedx_library = require("@cyclonedx/cyclonedx-library");
_cyclonedx_cyclonedx_library = __toESM(_cyclonedx_cyclonedx_library, 1);
let node_module = require("node:module");
let node_fs_promises = require("node:fs/promises");
node_fs_promises = __toESM(node_fs_promises, 1);
let normalize_package_data = require("normalize-package-data");
normalize_package_data = __toESM(normalize_package_data, 1);
let packageurl_js = require("packageurl-js");
let _cyclonedx_cyclonedx_library_Contrib_FromNodePackageJson = require("@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 (0, node_path.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 _cyclonedx_cyclonedx_library.Models.OrganizationalEntity({
name: option.name,
url: new Set(option.url),
contact: new _cyclonedx_cyclonedx_library.Models.OrganizationalContactRepository(option.contact.map((contact) => new _cyclonedx_cyclonedx_library.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(`${node_path.default.sep}package.json`) ? node_path.default.resolve(dirOrFilePath) : node_path.default.resolve(dirOrFilePath, "package.json");
return parsePackage(await node_fs_promises.default.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);
(0, normalize_package_data.default)(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 (node_path.default.dirname(currentDir) !== currentDir) {
const pkgPath = node_path.default.join(currentDir, "package.json");
try {
if (!(await node_fs_promises.default.stat(pkgPath)).isFile()) {
currentDir = node_path.default.dirname(currentDir);
continue;
}
const pkg = await readPackage(pkgPath);
if (pkg.name && pkg.version) return {
path: node_path.default.dirname(pkgPath),
package: pkg
};
} catch {}
try {
if ((await node_fs_promises.default.stat(node_path.default.join(currentDir, ".git"))).isDirectory()) {
context.warn(`Package finder did not find any result and reached the git directory while resolving ${startDir}`);
break;
}
} catch {}
currentDir = node_path.default.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 _cyclonedx_cyclonedx_library.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 = (0, node_module.createRequire)((0, node_path.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: _cyclonedx_cyclonedx_library.Spec.Version.v1dot7,
rootComponentType: _cyclonedx_cyclonedx_library.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 (!_cyclonedx_cyclonedx_library_Contrib_FromNodePackageJson.Utils.defaultRegistryMatcher.test(tarball)) qualifiers[packageurl_js.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[packageurl_js.PurlQualifierNames.VcsUrl] = url.toString();
} catch {}
try {
return new packageurl_js.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 _cyclonedx_cyclonedx_library.Contrib.FromNodePackageJson.Factories.ExternalReferenceFactory();
const cdxLicenseFactory = new _cyclonedx_cyclonedx_library.Contrib.License.Factories.LicenseFactory(spdx_expression_parse.default);
const cdxToolBuilder = new _cyclonedx_cyclonedx_library.Contrib.FromNodePackageJson.Builders.ToolBuilder(cdxExternalReferenceFactory);
const cdxLicenseEvidenceGatherer = new _cyclonedx_cyclonedx_library.Contrib.License.Utils.LicenseEvidenceGatherer();
const cdxComponentBuilder = new _cyclonedx_cyclonedx_library.Contrib.FromNodePackageJson.Builders.ComponentBuilder(cdxExternalReferenceFactory, cdxLicenseFactory);
const jsonSerializer = new _cyclonedx_cyclonedx_library.Serialize.JsonSerializer(new _cyclonedx_cyclonedx_library.Serialize.JSON.Normalize.Factory(_cyclonedx_cyclonedx_library.Spec.SpecVersionDict[options.specVersion]));
const xmlSerializer = new _cyclonedx_cyclonedx_library.Serialize.XmlSerializer(new _cyclonedx_cyclonedx_library.Serialize.XML.Normalize.Factory(_cyclonedx_cyclonedx_library.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 = _cyclonedx_cyclonedx_library.Enums.LicenseAcknowledgement.Declared;
});
if (options.collectLicenseEvidence && Array.isArray(licenseEvidence) && licenseEvidence.length > 0) {
component.evidence = new _cyclonedx_cyclonedx_library.Models.ComponentEvidence({ licenses: new _cyclonedx_cyclonedx_library.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 _cyclonedx_cyclonedx_library.Models.Bom({ metadata: new _cyclonedx_cyclonedx_library.Models.Metadata({
supplier: options.supplier && convertOrganizationalEntityOptionToModel(options.supplier),
properties: options.properties && new _cyclonedx_cyclonedx_library.Models.PropertyRepository(options.properties.map(({ name, value }) => new _cyclonedx_cyclonedx_library.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(_cyclonedx_cyclonedx_library.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 = _cyclonedx_cyclonedx_library.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 = (0, node_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: " "
})
});
}
}
};
}
//#endregion
module.exports = rollupPluginSbom;
//# sourceMappingURL=index.cjs.map