UNPKG

@cyclonedx/cdxgen

Version:

Creates CycloneDX Software Bill of Materials (SBOM) from source or container image

1,725 lines (1,684 loc) 338 kB
import { Buffer } from "node:buffer"; import { accessSync, constants, lstatSync, readdirSync, readFileSync, statSync, } from "node:fs"; import { platform as _platform, arch, homedir } from "node:os"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; import process from "node:process"; import { PackageURL } from "packageurl-js"; import { gte, lte } from "semver"; import { parse } from "ssri"; import { v4 as uuidv4 } from "uuid"; import { parse as loadYaml } from "yaml"; import { AI_INSTRUCTION_FILE_KINDS, AI_INVENTORY_PROJECT_TYPES, AI_SKILL_FILE_KIND, collectAiInventory, filterInventoryDependencies, filterInventorySubjectsByTypes, inventoryPropertyValue, MCP_CONFIG_FILE_KIND, optionIncludesAiInventoryProjectType, summarizeAiInventory, } from "../helpers/aiInventory.js"; import { detectMcpInventory, detectPythonMcpInventory, findJSImportsExports, } from "../helpers/analyzer.js"; import { cleanupAsarTempDir, extractAsarToTempDir, parseAsarArchive, rewriteExtractedArchivePaths, } from "../helpers/asarutils.js"; import { expandBomAuditCategories } from "../helpers/auditCategories.js"; import { setCycloneDxFormat, toCycloneDxSpecVersionString, } from "../helpers/bomUtils.js"; import { parseCaxaMetadata } from "../helpers/caxa.js"; import { collectDosaiCryptoComponents, collectSourceCryptoComponents, } from "../helpers/cbomutils.js"; import { CHROME_EXTENSION_PURL_TYPE, collectChromeExtensionsFromPath, collectInstalledChromeExtensions, discoverChromiumExtensionDirs, } from "../helpers/chromextutils.js"; import { mergeDependencies, mergeServices, trimComponents, } from "../helpers/depsUtils.js"; import { collectDosaiServicesFromMethods, createDosaiMethodsSlice, isDosaiDotnetLanguage, normalizeDosaiServiceMap, readDosaiJsonFile, } from "../helpers/dosai.js"; import { GIT_COMMAND } from "../helpers/envcontext.js"; import { createHbomDocument, ensureHbomRuntimeSupport, ensureNoMixedHbomProjectTypes, hasHbomProjectType, } from "../helpers/hbom.js"; import { mergeHostInventoryBoms } from "../helpers/hostTopology.js"; import { thoughtLog } from "../helpers/logger.js"; import { enrichComponentWithMcpMetadata } from "../helpers/mcp.js"; import { isPyLockFile } from "../helpers/pylockutils.js"; import { buildDependencyTrackBomPayload, getDependencyTrackBomApiUrl, } from "../helpers/remote/dependency-track.js"; import { table } from "../helpers/table.js"; import { addEvidenceForDotnet, addEvidenceForImports, addPlugin, attachIdentityTools, buildGradleCommandArguments, buildObjectForCocoaPod, buildObjectForGradleModule, CARGO_CMD, CDXGEN_VERSION, CLJ_CMD, cdxgenAgent, checksumFile, cleanupPlugin, collectGemModuleNames, collectGradleDependencies, collectJarNS, collectMvnDependencies, convertJarNSToPackages, convertOSQueryResults, createNpmWorkspacePurl, createUVLock, DEBUG_MODE, DOTNET_CMD, determineSbtVersion, dirNameStr, encodeForPurl, executeParallelGradleProperties, executePodCommand, extractJarArchive, extractToolRefs, frameworksList, generatePixiLockFile, getAllFiles, getCppModules, getCratesMetadata, getGradleCommand, getLicenses, getMavenCommand, getMillCommand, getMvnMetadata, getNugetMetadata, getPipFrozenTree, getPipTreeForPackages, getPropertyGroupTextNodes, getPyMetadata, getPyModules, getSwiftPackageMetadata, getTimestamp, getTmpDir, hasAnyProjectType, includeMavenTestScope, isAllowedHttpHost, isDryRun, isFeatureEnabled, isMac, isPackageManagerAllowed, isPartialTree, isSecureMode, isValidIriReference, LEIN_CMD, MAX_BUFFER, PREFER_MAVEN_DEPS_TREE, PROJECT_TYPE_ALIASES, parseBazelActionGraph, parseBazelSkyframe, parseBdistMetadata, parseBitbucketPipelinesFile, parseBowerJson, parseCabalData, parseCargoData, parseCargoDependencyData, parseCargoManifestDependencyData, parseCargoTomlData, parseCljDep, parseCloudBuildData, parseCmakeLikeFile, parseCocoaDependency, parseColliderLockData, parseComposerJson, parseComposerLock, parseConanData, parseConanLockData, parseContainerFile, parseContainerSpecData, parseCsPkgData, parseCsPkgLockData, parseCsProjAssetsData, parseCsProjData, parseEdnData, parseFlakeLock, parseFlakeNix, parseGemfileLockData, parseGemspecData, parseGitHubWorkflowData, parseGoListDep, parseGoModData, parseGoModGraph, parseGoModulesTxt, parseGoModWhy, parseGopkgData, parseGosumData, parseGradleDep, parseGradleProperties, parseHelmYamlData, parseLeinDep, parseLeiningenData, parseMakeDFile, parseMavenTree, parseMillDependency, parseMinJs, parseMixLockData, parseNodeShrinkwrap, parseNupkg, parseOpenapiSpecData, parsePackageJsonName, parsePaketLockData, parsePiplockData, parsePixiLockFile, parsePixiTomlFile, parsePkgJson, parsePkgLock, parsePnpmLock, parsePnpmWorkspace, parsePodfileLock, parsePodfileTargets, parsePom, parsePrivadoFile, parsePubLockData, parsePubYamlData, parsePyLockData, parsePyProjectTomlFile, parseReqFile, parseSbtLock, parseSbtTree, parseSetupPyFile, parseSwiftJsonTree, parseSwiftResolved, parseYarnLock, parseYarnWorkspace, readZipEntry, recomputeScope, recordActivity, recordSensitiveFileRead, resetActivityContext, SWIFT_CMD, safeExistsSync, safeMkdirSync, safeMkdtempSync, safeRmSync, safeSpawnSync, safeUnlinkSync, safeWriteSync, setActivityContext, shouldFetchLicense, splitOutputByGradleProjects, } from "../helpers/utils.js"; export { summarizeAiInventory } from "../helpers/aiInventory.js"; import { cleanupTempDir, collectInstalledExtensions, discoverIdeExtensionDirs, extractVsixToTempDir, parseVsixFile, VSCODE_EXTENSION_PURL_TYPE, } from "../helpers/vsixutils.js"; import { enrichOSComponentsWithTrustData, executeOsQuery, getBinaryBom, getOSPackages, getPluginToolComponents, } from "../managers/binary.js"; import { addSkippedSrcFiles, exportArchive, exportImage, getPkgPathList, parseImageName, } from "../managers/docker.js"; import { DEFAULT_NPMRC_BLOCKLIST, parseNpmrc } from "../parsers/npmrc.js"; const dirName = dirNameStr; const isWin = _platform() === "win32"; let osQueries = {}; switch (_platform()) { case "win32": osQueries = JSON.parse( readFileSync(join(dirName, "data", "queries-win.json"), "utf-8"), ); break; case "darwin": osQueries = JSON.parse( readFileSync(join(dirName, "data", "queries-darwin.json"), "utf-8"), ); break; default: osQueries = JSON.parse( readFileSync(join(dirName, "data", "queries.json"), "utf-8"), ); break; } const cosDbQueries = JSON.parse( readFileSync(join(dirName, "data", "cosdb-queries.json"), "utf-8"), ); // Construct gradle cache directory let GRADLE_CACHE_DIR = process.env.GRADLE_CACHE_DIR || join(homedir(), ".gradle", "caches", "modules-2", "files-2.1"); if (process.env.GRADLE_USER_HOME) { GRADLE_CACHE_DIR = join( process.env.GRADLE_USER_HOME, "caches", "modules-2", "files-2.1", ); } // Construct path to gradle init script const GRADLE_INIT_SCRIPT = resolve( dirNameStr, "data", "helpers", "init.gradle", ); // Construct sbt cache directory const SBT_CACHE_DIR = process.env.SBT_CACHE_DIR || join(homedir(), ".ivy2", "cache"); function getCargoCacheDir() { return process.env.CARGO_CACHE_DIR ? resolve(process.env.CARGO_CACHE_DIR) : resolve( process.env.CARGO_HOME || join(homedir(), ".cargo"), "registry", "cache", ); } // CycloneDX Hash pattern const HASH_PATTERN = "^([a-fA-F0-9]{32}|[a-fA-F0-9]{40}|[a-fA-F0-9]{64}|[a-fA-F0-9]{96}|[a-fA-F0-9]{128})$"; /** * Creates a default parent component based on the directory name. * * @param {String} path Directory or file name * @param {String} type Package type * @param {Object} options CLI options * @returns component object */ const createDefaultParentComponent = ( path, type = "application", options = {}, ) => { // Expands any relative path such as dot path = resolve(path); // Create a parent component based on the directory name let dirNameStr = safeExistsSync(path) && lstatSync(path).isDirectory() ? basename(path) : dirname(path); const tmpA = dirNameStr.split(sep); dirNameStr = tmpA[tmpA.length - 1]; const compName = "project-name" in options ? options.projectName : dirNameStr; const parentComponent = { group: options.projectGroup || "", name: compName, version: `${options.projectVersion}` || "latest", type: type === "container" || compName.endsWith(".tar") ? "container" : "application", }; const ppurl = new PackageURL( type, parentComponent.group, parentComponent.name, parentComponent.version, null, null, ).toString(); parentComponent["bom-ref"] = decodeURIComponent(ppurl); parentComponent["purl"] = ppurl; return parentComponent; }; const shouldIncludeNodeModulesDir = (options = {}, baseProjectTypes = []) => { if (options.deep) { return true; } const projectTypes = Array.isArray(options.projectType) ? options.projectType : options.projectType ? [options.projectType] : []; if (!projectTypes.length) { return true; } return baseProjectTypes.some((projectType) => projectTypes.every((selectedProjectType) => PROJECT_TYPE_ALIASES[projectType]?.includes(selectedProjectType), ), ); }; const hasExplicitProjectTypeSelection = (options, baseProjectType) => { options = options || {}; const projectTypes = Array.isArray(options.projectType) ? options.projectType : options.projectType ? [options.projectType] : []; return projectTypes.some((selectedProjectType) => PROJECT_TYPE_ALIASES[baseProjectType]?.includes(selectedProjectType), ); }; const hasDotnetProjectIndicators = (src, options = {}) => { return Boolean( getAllFiles(src, "**/*.{csproj,fsproj,vbproj,sln}", options)?.length, ); }; const shouldCollectDosaiCrypto = (src, options = {}) => { const projectTypes = Array.isArray(options.projectType) ? options.projectType : options.projectType ? [options.projectType] : []; if (projectTypes.some((projectType) => isDosaiDotnetLanguage(projectType))) { return true; } if (!projectTypes.length || projectTypes.includes("universal")) { return hasDotnetProjectIndicators(src, options); } return false; }; const determineParentComponent = (options) => { let parentComponent; if (options.parentComponent && Object.keys(options.parentComponent).length) { return options.parentComponent; } if (options.projectName && options.projectVersion) { parentComponent = { group: options.projectGroup || "", name: options.projectName, version: `${options.projectVersion}` || "", type: "application", }; const ppurl = new PackageURL( parentComponent.type, parentComponent.group, parentComponent.name, parentComponent.version, null, null, ).toString(); parentComponent["bom-ref"] = decodeURIComponent(ppurl); parentComponent["purl"] = ppurl; } return parentComponent; }; const addToolsSection = (options, context = {}) => { if (options.specVersion === 1.4) { return [ { vendor: "cyclonedx", name: "cdxgen", version: CDXGEN_VERSION, }, ]; } let components = []; const tools = options.tools || context.tools || []; // tools can be an object or array if (Array.isArray(tools) && tools.length) { // cyclonedx-maven-plugin has the legacy tools metadata which needs to be patched for (const tool of tools) { if (!tool.type) { tool.type = "application"; if (tool.vendor) { tool.publisher = tool.vendor; delete tool.vendor; } } } components = components.concat(tools); } else if (tools && Object.keys(tools).length && tools.components) { components = components.concat(tools.components); } const cdxToolComponent = { group: "@cyclonedx", name: "cdxgen", version: CDXGEN_VERSION, purl: `pkg:npm/%40cyclonedx/cdxgen@${CDXGEN_VERSION}`, type: "application", "bom-ref": `pkg:npm/@cyclonedx/cdxgen@${CDXGEN_VERSION}`, author: "OWASP Foundation", publisher: "OWASP Foundation", }; if (options.specVersion >= 1.6) { cdxToolComponent.authors = [{ name: "OWASP Foundation" }]; delete cdxToolComponent.author; } components.push(cdxToolComponent); return { components }; }; const componentToSimpleFullName = (comp) => { let fullName = comp.group?.length ? `${comp.group}/${comp.name}` : comp.name; if (comp.version?.length) { fullName = `${fullName}@${comp.version}`; } return fullName; }; // Remove unwanted properties from parent component // Bug #1519 - Retain licenses and external references const cleanParentComponent = (comp) => { delete comp.evidence; delete comp._integrity; if (comp.license) { const licenses = getLicenses(comp); if (licenses?.length) { comp.licenses = licenses; } } delete comp.license; delete comp.qualifiers; if (comp.repository || comp.homepage) { const externalReferences = addExternalReferences(comp); if (externalReferences?.length) { comp.externalReferences = externalReferences; } } delete comp.repository; delete comp.homepage; return comp; }; const addAuthorsSection = (options) => { const authors = []; if (options.author) { const oauthors = Array.isArray(options.author) ? options.author : [options.author]; for (const aauthor of oauthors) { if (aauthor.trim().length < 2) { continue; } authors.push({ name: aauthor }); } } return authors; }; /** * Method to generate metadata.lifecycles section. We assume that we operate during "build" * most of the time and under "post-build" for containers. * * @param {Object} options * @returns {Array} Lifecycles array */ const addLifecyclesSection = (options) => { // If lifecycle was set via CLI arguments, reuse the value if (options.lifecycle) { return [{ phase: options.lifecycle }]; } const lifecycles = [{ phase: options.installDeps ? "build" : "pre-build" }]; if (options.exportData) { const inspectData = options.exportData.inspectData; if (inspectData) { lifecycles.push({ phase: "post-build" }); } } else if ( options?.projectType?.length && options?.projectType?.includes("binary") ) { lifecycles.push({ phase: "post-build" }); } if (options.projectType?.includes("os")) { lifecycles.push({ phase: "operations" }); } return lifecycles; }; /** * Function to create metadata block * */ function addMetadata(parentComponent = {}, options = {}, context = {}) { // DO NOT fork this project to just change the vendor or author's name // Try to contribute to this project by sending PR or filing issues const tools = addToolsSection(options, context); const authors = addAuthorsSection(options); const lifecycles = options.specVersion >= 1.5 ? addLifecyclesSection(options) : undefined; const metadata = { timestamp: getTimestamp(), tools, authors, supplier: undefined, }; if (lifecycles) { metadata.lifecycles = lifecycles; } // TLP classification if (options.specVersion >= 1.7 && options?.tlpClassification) { metadata.distributionConstraints = { tlp: options.tlpClassification }; } if (parentComponent && Object.keys(parentComponent).length) { if (parentComponent) { cleanParentComponent(parentComponent); if (!parentComponent["purl"] && parentComponent["bom-ref"]) { parentComponent["purl"] = encodeForPurl(parentComponent["bom-ref"]); } } if (parentComponent?.components) { parentComponent.components = listComponents( options, {}, parentComponent.components, ); const parentFullName = componentToSimpleFullName(parentComponent); const subComponents = []; const addedSubComponents = {}; for (const comp of parentComponent.components) { cleanParentComponent(comp); if (comp.name && comp.type) { const fullName = componentToSimpleFullName(comp); // Fixes #479 // Prevent the parent component from also appearing as a sub-component // We cannot use purl or bom-ref here since they would not match // purl - could have application on one side and a different type // bom-ref could have qualifiers on one side if (fullName !== parentFullName) { if (!comp["bom-ref"]) { comp["bom-ref"] = `pkg:${comp.type}/${decodeURIComponent( fullName, )}`; } if (!addedSubComponents[comp["bom-ref"]]) { subComponents.push(comp); addedSubComponents[comp["bom-ref"]] = true; } } } } // for // Avoid creating empty component.components attribute if (subComponents.length) { parentComponent.components = subComponents; } else { parentComponent.components = undefined; } } // Convert authors to author for specVersion < 1.6 const parentComponentCopy = { ...parentComponent }; if (options.specVersion < 1.6 && parentComponent?.authors) { parentComponentCopy.author = parentComponentCopy.authors .map((a) => (a.email ? `${a.name} <${a.email}>` : a.name)) .join(","); delete parentComponentCopy.authors; } metadata.component = parentComponent; } // Have we already captured the oci properties if (metadata?.properties?.some((prop) => prop.name === "oci:image:Id")) { return metadata; } if (options) { const mproperties = []; if (options.exportData) { const inspectData = options.exportData.inspectData; if (inspectData) { if (inspectData.Id) { mproperties.push({ name: "oci:image:Id", value: inspectData.Id, }); } if ( inspectData.RepoTags && Array.isArray(inspectData.RepoTags) && inspectData.RepoTags.length ) { mproperties.push({ name: "oci:image:RepoTag", value: inspectData.RepoTags[0], }); } if ( inspectData.RepoDigests && Array.isArray(inspectData.RepoDigests) && inspectData.RepoDigests.length ) { mproperties.push({ name: "oci:image:RepoDigest", value: inspectData.RepoDigests[0], }); } if (inspectData.Created) { mproperties.push({ name: "oci:image:Created", value: inspectData.Created, }); } if (inspectData.Architecture) { mproperties.push({ name: "oci:image:Architecture", value: inspectData.Architecture, }); } if (inspectData.Os) { mproperties.push({ name: "oci:image:Os", value: inspectData.Os, }); } } const manifestList = options.exportData.manifest; if (manifestList && Array.isArray(manifestList) && manifestList.length) { const manifest = manifestList[0] || {}; if (manifest.Config) { mproperties.push({ name: "oci:image:manifest:Config", value: manifest.Config, }); } if ( manifest.Layers && Array.isArray(manifest.Layers) && manifest.Layers.length ) { mproperties.push({ name: "oci:image:manifest:Layers", value: manifest.Layers.join("\\n"), }); } } const lastLayerConfig = options.exportData.lastLayerConfig; if (lastLayerConfig) { if (lastLayerConfig.id) { mproperties.push({ name: "oci:image:lastLayer:Id", value: lastLayerConfig.id, }); } if (lastLayerConfig.parent) { mproperties.push({ name: "oci:image:lastLayer:ParentId", value: lastLayerConfig.parent, }); } if (lastLayerConfig.created) { mproperties.push({ name: "oci:image:lastLayer:Created", value: lastLayerConfig.created, }); } } const layerConfig = lastLayerConfig?.config || options.exportData?.inspectData; if (layerConfig) { const env = layerConfig?.config?.Env || layerConfig?.Config?.Env; if (env && Array.isArray(env) && env.length) { mproperties.push({ name: "oci:image:lastLayer:Env", value: env.join("\\n"), }); // Does the image have any special packages that cdxgen cannot detect such as android-sdk and sdkman const evalue = env.join(":"); if ( evalue.includes("android-sdk") || evalue.includes("commandlinetools") ) { mproperties.push({ name: "oci:image:bundles:AndroidSdk", value: "true", }); } // Track the use of special environment variables that could influence the search paths for libraries // This list was generated by repeatedly prompting ChatGPT with examples. // FIXME: Move these to a config file for (const senvValue of [ "LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "LD_PRELOAD", "PYTHONPATH", "CLASSPATH", "PERL5LIB", "PERLLIB", "RUBYLIB", "NODE_PATH", "LUA_PATH", "JULIA_LOAD_PATH", "R_LIBS", "R_LIBS_USER", "GEM_PATH", "DOTNET_ROOT", "DOTNET_ADDITIONAL_DEPS", "DOTNET_SHARED_STORE", "DOTNET_STARTUP_HOOKS", "DOTNET_BUNDLE_EXTRACT_BASE_DIR", "JAVA_OPTIONS", "JAVA_TOOL_OPTIONS", "NODE_OPTIONS", "PYTHONSTARTUP", "RUBYOPT", "WGETRC", "APT_CONFIG", "NPM_CONFIG_PREFIX", "NPM_CONFIG_REGISTRY", "YARN_CACHE_FOLDER", "PNPM_STORE_PATH", "PNPM_HOME", "PNPM_CONFIG_", "GIO_MODULE_DIR", "GST_PLUGIN_PATH", "GST_PLUGIN_SYSTEM_PATH", "APPDIR_LIBRARY_PATH", // appimage specific which gets prepended to LD_LIBRARY_PATH ]) { if (evalue.includes(senvValue)) { mproperties.push({ name: `oci:image:env:${senvValue}`, value: "true", }); } } // This value represents a filtered and expanded path if (options?.binPaths?.length) { mproperties.push({ name: "oci:image:env:PATH", value: options.binPaths.join(":"), }); } if (evalue.includes(".sdkman")) { mproperties.push({ name: "oci:image:bundles:Sdkman", value: "true", }); } if (evalue.includes(".nvm")) { mproperties.push({ name: "oci:image:bundles:Nvm", value: "true", }); } if (evalue.includes(".rbenv")) { mproperties.push({ name: "oci:image:bundles:Rbenv", value: "true", }); } } const ccmd = layerConfig?.config?.Cmd || layerConfig?.Config?.Cmd || layerConfig?.Config?.EntryPoint; if (ccmd) { if (Array.isArray(ccmd) && ccmd.length) { const fullCommand = ccmd.join(" "); mproperties.push({ name: "oci:image:lastLayer:Cmd", value: fullCommand, }); let appLanguage; // TODO: Move these lists to a config file. for (const lang in [ "java", "node", "dotnet", "python", "python3", "ruby", "php", "php7", "php8", "perl", ]) { if (fullCommand.includes(`${lang} `)) { appLanguage = lang; break; } } if (appLanguage) { mproperties.push({ name: "oci:image:appLanguage", value: appLanguage, }); } } else { mproperties.push({ name: "oci:image:lastLayer:Cmd", value: ccmd.toString(), }); } } } } if (options.allOSComponentTypes?.length) { mproperties.push({ name: "oci:image:componentTypes", value: options.allOSComponentTypes.sort().join("\\n"), }); } if (Number.isInteger(options?.unpackagedExecutableCount)) { mproperties.push({ name: "cdx:container:unpackagedExecutableCount", value: String(options.unpackagedExecutableCount), }); } if (Number.isInteger(options?.unpackagedSharedLibraryCount)) { mproperties.push({ name: "cdx:container:unpackagedSharedLibraryCount", value: String(options.unpackagedSharedLibraryCount), }); } // Should we move these to formulation? if (options?.bundledSdks?.length) { for (const sdk of options.bundledSdks) { try { const purlObj = PackageURL.fromString(sdk); const sdkName = purlObj.name.split("-")[0].toLowerCase(); mproperties.push({ name: `oci:image:bundles:${sdkName}Sdk`, value: "true", }); } catch (_e) { // ignore } } } if (options?.bundledRuntimes?.length) { for (const runt of options.bundledRuntimes) { mproperties.push({ name: `oci:image:bundles:${runt}Runtime`, value: "true", }); } } if (mproperties.length) { metadata.properties = mproperties; } } return metadata; } /** * Method to create external references * * @param {Array | Object} opkg * @returns {Array} */ function addExternalReferences(opkg) { let externalReferences = []; let pkgList; if (Array.isArray(opkg)) { pkgList = opkg; } else { pkgList = [opkg]; } for (const pkg of pkgList) { if (pkg.externalReferences) { externalReferences = externalReferences.concat(pkg.externalReferences); } else { if (pkg.homepage?.url) { externalReferences.push({ type: pkg.homepage.url.includes("git") ? "vcs" : "website", url: pkg.homepage.url, }); } if (pkg.bugs?.url) { externalReferences.push({ type: "issue-tracker", url: pkg.bugs.url, }); } if (pkg.repository?.url) { externalReferences.push({ type: "vcs", url: pkg.repository.url, }); } if (pkg.distribution?.url) { externalReferences.push({ type: "distribution", url: pkg.distribution.url, }); } } } return externalReferences .map((reference) => ({ ...reference, url: reference.url.trim() })) .filter((reference) => isValidIriReference(reference.url)); } /** * For all modules in the specified package, creates a list of * component objects from each one. * * @param {Object} options CLI options * @param {Object} allImports All imports * @param {Object} pkg Package object * @param {string} ptype Package type * @returns {Object[]} Array of component objects */ export function listComponents(options, allImports, pkg, ptype = "npm") { const compMap = {}; const isRootPkg = ptype === "npm"; if (Array.isArray(pkg)) { pkg.forEach((p) => { addComponent(options, allImports, p, ptype, compMap, false); }); } else { addComponent(options, allImports, pkg, ptype, compMap, isRootPkg); } return Object.keys(compMap).map((k) => compMap[k]); } // These component types do not have PURLs const NON_PURL_TYPES = ["cryptographic-asset", "file", "data"]; /** * Given the specified package, create a CycloneDX component and add it to the list. */ function addComponent( options, allImports, pkg, ptype, compMap, isRootPkg = false, ) { if (!pkg || pkg.extraneous) { return; } if (!isRootPkg) { const pkgIdentifier = parsePackageJsonName(pkg.name); const author = pkg.author || undefined; const authors = pkg.authors || undefined; const publisher = pkg.publisher || undefined; let group = pkg.group || pkgIdentifier.scope; // Create empty group group = group || ""; const name = pkgIdentifier.fullName || pkg.name || ""; // name is mandatory if (!name) { return; } // Do we need this still? if ( !ptype && ["jar", "war", "ear", "pom"].includes(pkg?.qualifiers?.type) ) { ptype = "maven"; } const version = pkg.version || ""; const licenses = pkg.licenses || getLicenses(pkg); let purl = pkg.purl; if (!purl && ptype) { purl = new PackageURL( ptype, encodeForPurl(group), encodeForPurl(name), version, pkg.qualifiers, encodeForPurl(pkg.subpath), ); } let purlString = purl?.toString(); if (NON_PURL_TYPES.includes(ptype) || NON_PURL_TYPES.includes(pkg.type)) { purl = undefined; purlString = undefined; } // Some applications like github workflow steps and commands do not have purl if ( pkg.purl === undefined && !pkg?.["bom-ref"]?.startsWith("pkg:") && pkg?.type === "application" ) { purl = undefined; purlString = undefined; } const description = pkg.description || undefined; let compScope = pkg.scope; if (allImports) { const impPkgs = Object.keys(allImports); if ( impPkgs.includes(name) || impPkgs.includes(`${group}/${name}`) || impPkgs.includes(`@${group}/${name}`) || impPkgs.includes(group) || impPkgs.includes(`@${group}`) ) { compScope = "required"; } else if (impPkgs.length && compScope !== "excluded") { compScope = "optional"; } } let component = { author, authors, publisher, group, name, version, description, scope: compScope, hashes: [], licenses, purl: purlString, externalReferences: addExternalReferences(pkg), }; if (options.specVersion >= 1.5) { component.pedigree = pkg.pedigree || undefined; } if (options.specVersion >= 1.6) { component.releaseNotes = pkg.releaseNotes || undefined; component.modelCard = pkg.modelCard || undefined; component.data = pkg.data || undefined; } component["type"] = determinePackageType(pkg); if (purlString) { component["bom-ref"] = decodeURIComponent(purlString); } else if (pkg["bom-ref"]) { component["bom-ref"] = pkg["bom-ref"]; } if ( component.externalReferences === undefined || component.externalReferences.length === 0 ) { delete component.externalReferences; } if (options.specVersion < 1.6) { delete component.omniborId; delete component.swhid; } processHashes(pkg, component); // Upgrade authors section if (options.specVersion >= 1.6 && component.author) { const authorsList = []; for (const aauthor of component.author.split(",")) { authorsList.push({ name: aauthor }); } component.authors = authorsList; delete component.author; } // Downgrade authors section for < 1.5 :( if (options.specVersion < 1.6) { if (component?.authors?.length) { component.author = component.authors .map((a) => (a.email ? `${a.name} <${a.email}>` : a.name)) .join(","); } delete component.authors; } // Downgrade from 1.7 if (options.specVersion < 1.7) { if (component.isExternal) { delete component.isExternal; } if (component.versionRange) { console.warn( `Version Range is not supported in ${options.specVersion} specifications. Please run cdxgen with --spec-version 1.7`, ); delete component.versionRange; } } // Retain any tags if ( options.specVersion >= 1.6 && pkg.tags && Object.keys(pkg.tags).length ) { component.tags = pkg.tags; } // Retain any component properties and crypto properties if (pkg.properties?.length) { component.properties = pkg.properties; } if (pkg.cryptoProperties && typeof pkg.cryptoProperties === "object") { component.cryptoProperties = pkg.cryptoProperties; } // Retain nested components if (pkg.components) { component.components = pkg.components; } component = enrichComponentWithMcpMetadata(component); const compMapKey = component.purl || component["bom-ref"]; // Issue: 1353. We need to keep merging the properties if (compMap[compMapKey]) { const mergedComponents = trimComponents([compMap[compMapKey], component]); if (mergedComponents?.length === 1) { component = mergedComponents[0]; } } // Retain evidence if ( options.specVersion >= 1.5 && pkg.evidence && Object.keys(pkg.evidence).length ) { component.evidence = pkg.evidence; // Convert evidence.identity section to an array for 1.6 and above if ( options.specVersion >= 1.6 && pkg.evidence && pkg.evidence.identity && !Array.isArray(pkg.evidence.identity) ) { // Automatically add concludedValue if (pkg.evidence.identity?.methods?.length === 1) { pkg.evidence.identity.concludedValue = pkg.evidence.identity.methods[0].value; } component.evidence.identity = [pkg.evidence.identity]; } // Convert evidence.identity section to an object for 1.5 if ( options.specVersion === 1.5 && pkg.evidence && pkg.evidence.identity && Array.isArray(pkg.evidence.identity) ) { component.evidence.identity = pkg.evidence.identity[0]; } } compMap[compMapKey] = component; } if (pkg.dependencies) { Object.keys(pkg.dependencies) .map((x) => pkg.dependencies[x]) .filter((x) => typeof x !== "string") //remove cycles .map((x) => addComponent(options, allImports, x, ptype, compMap, false)); } } /** * If the author has described the module as a 'framework', the take their * word for it, otherwise, identify the module as a 'library'. */ function determinePackageType(pkg) { // Retain the exact component type in certain cases. if ( [ "container", "platform", "operating-system", "device", "device-driver", "firmware", "file", "machine-learning-model", "data", "cryptographic-asset", ].includes(pkg.type) ) { return pkg.type; } if (pkg.type === "application") { if (pkg?.name?.endsWith(".tar")) { return "container"; } return pkg.type; } if (pkg.purl) { try { const purl = PackageURL.fromString(pkg.purl); if (purl.type) { if (["docker", "oci", "container"].includes(purl.type)) { return "container"; } if (["github"].includes(purl.type)) { return "application"; } } // See #1760 if ( purl.namespace?.startsWith("@types") || (purl.namespace?.includes("-types") && pkg?.type === "npm") ) { return "library"; } for (const cf of frameworksList.all) { if ( pkg.purl.startsWith(cf) || purl.namespace?.includes(cf) || purl.name.toLowerCase().startsWith(cf) ) { return "framework"; } } } catch (_e) { // continue regardless of error } } else if (pkg.group) { if (["actions"].includes(pkg.group)) { return "application"; } } if (Object.hasOwn(pkg, "description")) { if (pkg.description?.toLowerCase().includes("framework")) { return "framework"; } } if (Object.hasOwn(pkg, "keywords")) { for (const keyword of pkg.keywords) { if (keyword && keyword.toLowerCase() === "framework") { return "framework"; } } } if (Object.hasOwn(pkg, "tags")) { for (const tag of pkg.tags) { if (tag && tag.toLowerCase() === "framework") { return "framework"; } } } return "library"; } /** * Uses the SHA1 shasum (if present) otherwise utilizes Subresource Integrity * of the package with support for multiple hashing algorithms. */ function processHashes(pkg, component) { if (pkg.hashes) { // This attribute would be available when we read a bom json directly // Eg: cyclonedx-maven-plugin. See: Bugs: #172, #175 for (const ahash of pkg.hashes) { addComponentHash(ahash.alg, ahash.content, component); } } else if (pkg._shasum) { const ahash = { alg: "SHA-1", content: pkg._shasum }; component.hashes.push(ahash); } else if (pkg._integrity) { const integrity = parse(pkg._integrity) || {}; // Components may have multiple hashes with various lengths. Check each one // that is supported by the CycloneDX specification. if (Object.hasOwn(integrity, "sha512")) { addComponentHash("SHA-512", integrity.sha512[0].digest, component); } if (Object.hasOwn(integrity, "sha384")) { addComponentHash("SHA-384", integrity.sha384[0].digest, component); } if (Object.hasOwn(integrity, "sha256")) { addComponentHash("SHA-256", integrity.sha256[0].digest, component); } if (Object.hasOwn(integrity, "sha1")) { addComponentHash("SHA-1", integrity.sha1[0].digest, component); } } if (component.hashes.length === 0) { delete component.hashes; // If no hashes exist, delete the hashes node (it's optional) } } /** * Adds a hash to component. */ function addComponentHash(alg, digest, component) { let hash; // If it is a valid hash simply use it if (new RegExp(HASH_PATTERN).test(digest)) { hash = digest; } else { // Check if base64 encoded const isBase64Encoded = Buffer.from(digest, "base64").toString("base64") === digest; hash = isBase64Encoded ? Buffer.from(digest, "base64").toString("hex") : digest; } const ahash = { alg: alg, content: hash }; component.hashes.push(ahash); } /** * Return the BOM in json format including any namespace mapping * * @param {Object} options Options * @param {Object} pkgInfo Package information * @param {string} ptype Package type * @param {Object} context Context * * @returns {Object} BOM with namespace mapping */ const buildBomNSData = (options, pkgInfo, ptype, context) => { // Many create*Bom call sites provide only a source directory (`src`) when // there is no single manifest/lock file to report, so activity records must // fall back to that directory to keep the target populated. const sourcePath = context?.srcDir || context?.src || options.path || options.filePath; const activityProjectType = context?.projectType || (Array.isArray(options.projectType) ? options.projectType.length === 1 ? options.projectType[0] : undefined : options.projectType); setActivityContext({ packageType: ptype, sourcePath, ...(activityProjectType ? { projectType: activityProjectType } : {}), }); const bomNSData = { bomJson: undefined, bomJsonFiles: undefined, nsMapping: undefined, dependencies: undefined, parentComponent: undefined, }; const serialNum = `urn:uuid:${uuidv4()}`; let allImports = {}; if (context?.allImports) { allImports = context.allImports; } const nsMapping = context.nsMapping || {}; const dependencies = context.dependencies || []; const services = context.services || []; const parentComponent = determineParentComponent(options) || context.parentComponent; const metadata = addMetadata(parentComponent, options, context); const components = listComponents(options, allImports, pkgInfo, ptype); if (components && (components.length || parentComponent)) { // CycloneDX Json Template const jsonTpl = { bomFormat: "CycloneDX", specVersion: toCycloneDxSpecVersionString(options.specVersion || "1.7"), serialNumber: serialNum, version: 1, metadata: metadata, components, dependencies, }; setCycloneDxFormat(jsonTpl, jsonTpl.specVersion, { preserveLegacyBomFormat: true, }); if (services.length) { jsonTpl.services = mergeServices([], services); } bomNSData.bomJson = jsonTpl; bomNSData.nsMapping = nsMapping; bomNSData.dependencies = dependencies; bomNSData.parentComponent = parentComponent; // Carry language-specific formulation data (e.g. Pixi) so that // postProcess can merge it when building the final formulation section. if (context?.formulationList?.length) { bomNSData.formulationList = context.formulationList; } } recordActivity({ kind: "read", reason: `Collected ${ptype || "generic"} component metadata.`, status: components?.length || parentComponent ? "completed" : "failed", target: context?.filename || sourcePath, }); resetActivityContext(); return bomNSData; }; /** * Function to create bom string for Java jars * * @param {string} path to the project * @param {Object} options Parse options from the cli * * @returns {Object} BOM with namespace mapping */ export async function createJarBom(path, options) { let pkgList = []; let jarFiles; let nsMapping = {}; const searchOptions = { ...options, exclude: [...(options.exclude || [])], }; if (typeof searchOptions.includeNodeModulesDir === "undefined") { searchOptions.includeNodeModulesDir = shouldIncludeNodeModulesDir(options, [ "jar", "war", "ear", ]); } // Exclude certain directories during oci sbom generation if (hasAnyProjectType(["oci"], options, false)) { searchOptions.exclude.push("**/android-sdk*/**"); searchOptions.exclude.push("**/.sdkman/**"); } const parentComponent = createDefaultParentComponent(path, "maven", options); if (options.useGradleCache) { nsMapping = await collectGradleDependencies( getGradleCommand(path, null), path, false, true, ); } else if (options.useMavenCache) { nsMapping = await collectMvnDependencies( getMavenCommand(path, null), null, false, true, ); } if (path.endsWith(".jar")) { jarFiles = [resolve(path)]; } else { jarFiles = getAllFiles( path, `${options.multiProject ? "**/" : ""}*.[jw]ar`, searchOptions, ); } // Jenkins plugins const hpiFiles = getAllFiles( path, `${options.multiProject ? "**/" : ""}*.hpi`, searchOptions, ); if (hpiFiles.length) { jarFiles = jarFiles.concat(hpiFiles); } for (const jar of jarFiles) { const tempDir = safeMkdtempSync(join(getTmpDir(), "jar-deps-")); if (DEBUG_MODE) { console.log(`Parsing ${jar}`); } const dlist = await extractJarArchive(jar, tempDir); if (dlist?.length) { pkgList = pkgList.concat(dlist); } if (pkgList.length) { pkgList = await getMvnMetadata(pkgList); } // Clean up if (tempDir?.startsWith(getTmpDir())) { safeRmSync(tempDir, { recursive: true, force: true }); } } pkgList = pkgList.concat(await convertJarNSToPackages(nsMapping)); return buildBomNSData(options, pkgList, "maven", { src: path, parentComponent, }); } /** * Function to create bom string for Android apps using blint * * @param {string} path to the project * @param {Object} options Parse options from the cli * @returns {Object|undefined} BOM object */ export function createAndroidBom(path, options) { return createBinaryBom(path, options); } /** * Function to create bom string for binaries using blint * * @param {string} path to the project * @param {Object} options Parse options from the cli * @returns {Object|undefined} BOM object */ export function createBinaryBom(path, options) { const tempDir = safeMkdtempSync(join(getTmpDir(), "blint-tmp-")); const binaryBomFile = join(tempDir, "bom.json"); getBinaryBom(path, binaryBomFile, options.deep); if (safeExistsSync(binaryBomFile)) { const binaryBom = JSON.parse( readFileSync(binaryBomFile, { encoding: "utf-8" }), ); attachIdentityTools( binaryBom?.components, extractToolRefs( binaryBom?.metadata?.tools, (tool) => tool?.name !== "cdxgen", ), ); return { bomJson: binaryBom, dependencies: binaryBom.dependencies, parentComponent: binaryBom.parentComponent, }; } return undefined; } /** * Function to create bom string for Java projects * * @param {string} path to the project * @param {Object} options Parse options from the cli * @returns {Promise<Object>} Promise resolving to BOM object */ export async function createJavaBom(path, options) { let jarNSMapping = {}; let pkgList = []; let dependencies = []; // cyclone-dx-maven plugin creates a component for the app under metadata // This is subsequently referred to in the dependencies list let parentComponent = {}; // Support for tracking all the tools that created the BOM // For java, this would correctly include the cyclonedx maven plugin. let tools; let possible_misses = false; // war/ear mode if (path.endsWith(".war") || path.endsWith(".jar")) { // Check if the file exists if (safeExistsSync(path)) { if (DEBUG_MODE) { console.log(`Retrieving packages from ${path}`); } const tempDir = safeMkdtempSync(join(getTmpDir(), "war-deps-")); jarNSMapping = await collectJarNS(tempDir); pkgList = await extractJarArchive(path, tempDir, jarNSMapping); if (pkgList.length) { pkgList = await getMvnMetadata(pkgList); } // Clean up if (tempDir?.startsWith(getTmpDir())) { console.log(`Cleaning up ${tempDir}`); safeRmSync(tempDir, { recursive: true, force: true }); } } else { console.log(`${path} doesn't exist`); } return buildBomNSData(options, pkgList, "maven", { src: dirname(path), filename: path, nsMapping: jarNSMapping, dependencies, parentComponent, }); } // -t quarkus is supported let isQuarkus = options?.projectType?.includes("quarkus"); let useMavenDepsTree = isQuarkus ? false : PREFER_MAVEN_DEPS_TREE; // Is this a multi-module project let rootModules; // maven - pom.xml const pomFiles = getAllFiles( path, `${options.multiProject ? "**/" : ""}pom.xml`, options, ); // gradle const gradleFiles = getAllFiles( path, `${options.multiProject ? "**/" : ""}build.gradle*`, options, ); // mill const millFiles = getAllFiles( path, `${options.multiProject ? "**/" : ""}build.mill`, options, ); let bomJsonFiles = []; if ( pomFiles?.length && isPackageManagerAllowed( "maven", ["bazel", "sbt", "gradle", "mill"], options, ) ) { if (gradleFiles.length) { thoughtLog( `Is this a Gradle project? I recommend invoking cdxgen with the "-t gradle" option if you're encountering build errors.`, ); } if (!isQuarkus) { // Quarkus projects require special treatment. To detect quarkus, we parse the first 3 maven file to look for a hit for (const pf of pomFiles.slice(0, 3)) { const pomMap = parsePom(pf); if (!rootModules && pomMap?.modules?.length) { rootModules = pomMap.modules; } // In quarkus mode, we cannot use the maven deps tree if (pomMap.isQuarkus) { isQuarkus = true; useMavenDepsTree = false; break; } } } let result; let mvnArgs; // FIXME: How do we motivate everyone to upgrade to 1.7? const toolsSpecVersion = 1.6; if (isQuarkus) { thoughtLog( "This appears to be a Quarkus project. Let's use the right Maven plugin.", ); // disable analytics. See: https://quarkus.io/usage/ mvnArgs = [ "-fn", "quarkus:dependency-sbom", "-Dquarkus.analytics.disabled=true", ]; if (options.specVersion >= 1.6) { mvnArgs = mvnArgs.concat( `-Dquarkus.dependency.sbom.schema-version=${toolsSpecVersion}`, ); } } else { // FIXME: The last maven plugin release was on November 28th, 2024. // Should we fork this repo and maintain it ourselves? const cdxMavenPlugin = process.env.CDX_MAVEN_PLUGIN || "org.cyclonedx:cyclonedx-maven-plugin:2.9.1"; const cdxMavenGoal = process.env.CDX_MAVEN_GOAL || "makeAggregateBom"; mvnArgs = [ "-fn", `${cdxMavenPlugin}:${cdxMavenGoal}`, "-DoutputName=bom", ]; if (includeMavenTestScope) { mvnArgs.push("-DincludeTestScope=true"); } // By using quiet mode we can reduce the maxBuffer used and avoid crashes