rollup-plugin-sbom
Version:
A rollup and vite plugin to generate SBOMs for your application
458 lines (443 loc) • 16.8 kB
JavaScript
;
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;