UNPKG

@cyclonedx/cdxgen

Version:

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

1,707 lines (1,678 loc) 276 kB
import { Buffer } from "node:buffer"; import { spawnSync } from "node:child_process"; import { constants, accessSync, existsSync, lstatSync, mkdtempSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync, } 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 got from "got"; import { PackageURL } from "packageurl-js"; import { gte, lte } from "semver"; import { parse } from "ssri"; import { table } from "table"; import { v4 as uuidv4 } from "uuid"; import { parse as loadYaml } from "yaml"; import { findJSImportsExports } from "../helpers/analyzer.js"; import { collectOSCryptoLibs } from "../helpers/cbomutils.js"; import { collectEnvInfo, getBranch, getOriginUrl, gitTreeHashes, listFiles, } from "../helpers/envcontext.js"; import { thoughtLog } from "../helpers/logger.js"; import { CARGO_CMD, CLJ_CMD, DEBUG_MODE, LEIN_CMD, MAX_BUFFER, PREFER_MAVEN_DEPS_TREE, PROJECT_TYPE_ALIASES, SWIFT_CMD, TIMEOUT_MS, addEvidenceForDotnet, addEvidenceForImports, addPlugin, buildGradleCommandArguments, buildObjectForCocoaPod, buildObjectForGradleModule, checksumFile, cleanupPlugin, collectGemModuleNames, collectGradleDependencies, collectJarNS, collectMvnDependencies, convertJarNSToPackages, convertOSQueryResults, createUVLock, determineSbtVersion, dirNameStr, encodeForPurl, executeParallelGradleProperties, executePodCommand, extractJarArchive, frameworksList, generatePixiLockFile, getAllFiles, getCppModules, getGradleCommand, getLicenses, getMavenCommand, getMillCommand, getMvnMetadata, getNugetMetadata, getPipFrozenTree, getPipTreeForPackages, getPyMetadata, getPyModules, getSwiftPackageMetadata, getTimestamp, getTmpDir, hasAnyProjectType, includeMavenTestScope, isFeatureEnabled, isMac, isPackageManagerAllowed, isPartialTree, isSecureMode, isValidIriReference, parseBazelActionGraph, parseBazelSkyframe, parseBdistMetadata, parseBitbucketPipelinesFile, parseBowerJson, parseCabalData, parseCargoData, parseCargoDependencyData, parseCargoTomlData, parseCljDep, parseCloudBuildData, parseCmakeLikeFile, parseCocoaDependency, parseComposerJson, parseComposerLock, parseConanData, parseConanLockData, parseContainerFile, parseContainerSpecData, parseCsPkgData, parseCsPkgLockData, parseCsProjAssetsData, parseCsProjData, parseEdnData, parseGemfileLockData, parseGemspecData, parseGitHubWorkflowData, parseGoListDep, parseGoModData, parseGoModGraph, parseGoModWhy, parseGoModulesTxt, 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, readZipEntry, recomputeScope, safeExistsSync, safeMkdirSync, shouldFetchLicense, splitOutputByGradleProjects, } from "../helpers/utils.js"; import { executeOsQuery, getBinaryBom, getDotnetSlices, getOSPackages, } from "../managers/binary.js"; import { addSkippedSrcFiles, exportArchive, exportImage, getPkgPathList, parseImageName, } from "../managers/docker.js"; const dirName = dirNameStr; const selfPJson = JSON.parse( readFileSync(join(dirName, "package.json"), "utf-8"), ); const _version = selfPJson.version; 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"); // 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: 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 determineParentComponent = (options) => { let parentComponent = undefined; 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: _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: _version, purl: `pkg:npm/%40cyclonedx/cdxgen@${_version}`, type: "application", "bom-ref": `pkg:npm/@cyclonedx/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; }; /** * Method to generate the formulation section based on git metadata * * @param {Object} options * @param {Object} context Context * @returns {Array} formulation array */ const addFormulationSection = (options, context) => { const formulation = []; const provides = []; const gitBranch = getBranch(); const originUrl = getOriginUrl(); const gitFiles = listFiles(); const treeHashes = gitTreeHashes(); let parentOmniborId; let treeOmniborId; let components = []; const aformulation = {}; // Reuse any existing formulation components // See: PR #1172 if (context?.formulationList?.length) { components = components.concat(trimComponents(context.formulationList)); } if (options.specVersion >= 1.6 && Object.keys(treeHashes).length === 2) { parentOmniborId = `gitoid:blob:sha1:${treeHashes.parent}`; treeOmniborId = `gitoid:blob:sha1:${treeHashes.tree}`; components.push({ type: "file", name: "git-parent", description: "Git Parent Node.", "bom-ref": parentOmniborId, omniborId: [parentOmniborId], swhid: [`swh:1:rev:${treeHashes.parent}`], }); components.push({ type: "file", name: "git-tree", description: "Git Tree Node.", "bom-ref": treeOmniborId, omniborId: [treeOmniborId], swhid: [`swh:1:rev:${treeHashes.tree}`], }); provides.push({ ref: parentOmniborId, provides: [treeOmniborId], }); } // Collect git related components if (gitBranch && gitFiles) { const gitFileComponents = gitFiles.map((f) => options.specVersion >= 1.6 ? { type: "file", name: f.name, version: f.hash, omniborId: [f.omniborId], swhid: [f.swhid], } : { type: "file", name: f.name, version: f.hash, }, ); components = components.concat(gitFileComponents); // Complete the Artifact Dependency Graph if (options.specVersion >= 1.6 && treeOmniborId) { provides.push({ ref: treeOmniborId, provides: gitFiles.map((f) => f.ref), }); } } // Collect build environment details const infoComponents = collectEnvInfo(options.path); if (infoComponents?.length) { components = components.concat(infoComponents); } // Should we include the OS crypto libraries if (options.includeCrypto) { const cryptoLibs = collectOSCryptoLibs(options); if (cryptoLibs?.length) { components = components.concat(cryptoLibs); } } aformulation["bom-ref"] = uuidv4(); aformulation.components = trimComponents(components); let environmentVars = gitBranch?.length ? [{ name: "GIT_BRANCH", value: gitBranch }] : []; for (const aevar of Object.keys(process.env)) { if ( (aevar.startsWith("GIT") || aevar.startsWith("ANDROID") || aevar.startsWith("DENO") || aevar.startsWith("DOTNET") || aevar.startsWith("JAVA_") || aevar.startsWith("SDKMAN") || aevar.startsWith("CARGO") || aevar.startsWith("CONDA") || aevar.startsWith("RUST")) && !aevar.toLowerCase().includes("key") && !aevar.toLowerCase().includes("token") && !aevar.toLowerCase().includes("pass") && !aevar.toLowerCase().includes("secret") && !aevar.toLowerCase().includes("user") && !aevar.toLowerCase().includes("email") && process.env[aevar] && process.env[aevar].length ) { environmentVars.push({ name: aevar, value: process.env[aevar], }); } } if (!environmentVars.length) { environmentVars = undefined; } let sourceInput = undefined; if (environmentVars) { sourceInput = { environmentVars }; } const sourceWorkflow = { "bom-ref": uuidv4(), uid: uuidv4(), taskTypes: originUrl ? ["build", "clone"] : ["build"], }; if (sourceInput) { sourceWorkflow.inputs = [sourceInput]; } aformulation.workflows = [sourceWorkflow]; formulation.push(aformulation); return { formulation, provides }; }; /** * 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; } 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; } } 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"), }); } // 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 */ 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]); } /** * 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 || new PackageURL( ptype, encodeForPurl(group), encodeForPurl(name), version, pkg.qualifiers, encodeForPurl(pkg.subpath), ); let purlString = purl.toString(); // There is no purl for cryptographic-asset if (ptype === "cryptographic-asset") { 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); component["bom-ref"] = decodeURIComponent(purlString); 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; } // 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?.length) { component.cryptoProperties = pkg.cryptoProperties; } // Retain nested components if (pkg.components) { component.components = pkg.components; } // Issue: 1353. We need to keep merging the properties if (compMap[component.purl]) { const mergedComponents = trimComponents([ compMap[component.purl], 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[component.purl] = 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.prototype.hasOwnProperty.call(pkg, "description")) { if (pkg.description?.toLowerCase().includes("framework")) { return "framework"; } } if (Object.prototype.hasOwnProperty.call(pkg, "keywords")) { for (const keyword of pkg.keywords) { if (keyword && keyword.toLowerCase() === "framework") { return "framework"; } } } if (Object.prototype.hasOwnProperty.call(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.prototype.hasOwnProperty.call(integrity, "sha512")) { addComponentHash("SHA-512", integrity.sha512[0].digest, component); } if (Object.prototype.hasOwnProperty.call(integrity, "sha384")) { addComponentHash("SHA-384", integrity.sha384[0].digest, component); } if (Object.prototype.hasOwnProperty.call(integrity, "sha256")) { addComponentHash("SHA-256", integrity.sha256[0].digest, component); } if (Object.prototype.hasOwnProperty.call(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) => { 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 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: `${options.specVersion || "1.5"}`, serialNumber: serialNum, version: 1, metadata: metadata, components, dependencies, }; const formulationData = options.includeFormulation && options.specVersion >= 1.5 ? addFormulationSection(options, context) : undefined; if (formulationData) { jsonTpl.formulation = formulationData.formulation; } bomNSData.bomJson = jsonTpl; bomNSData.nsMapping = nsMapping; bomNSData.dependencies = dependencies; bomNSData.parentComponent = parentComponent; } 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 = {}; if (!options.exclude) { options.exclude = []; } // Exclude certain directories during oci sbom generation if (hasAnyProjectType(["oci"], options, false)) { options.exclude.push("**/android-sdk*/**"); options.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`, options, ); } // Jenkins plugins const hpiFiles = getAllFiles( path, `${options.multiProject ? "**/" : ""}*.hpi`, options, ); if (hpiFiles.length) { jarFiles = jarFiles.concat(hpiFiles); } for (const jar of jarFiles) { const tempDir = mkdtempSync(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()) && rmSync) { rmSync(tempDir, { recursive: true, force: true }); } } pkgList = pkgList.concat(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 */ 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 */ export function createBinaryBom(path, options) { const tempDir = mkdtempSync(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" }), ); 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 */ 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 = undefined; 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 = mkdtempSync(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()) && rmSync) { console.log(`Cleaning up ${tempDir}`); rmSync(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 = undefined; let mvnArgs; 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) { mvnArgs = mvnArgs.concat( `-Dquarkus.dependency.sbom.schema-version=${options.specVersion}`, ); } } else { 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 if (!DEBUG_MODE) { mvnArgs.push("-q"); } // Support for passing additional settings and profile to maven if (process.env.MVN_ARGS) { const addArgs = process.env.MVN_ARGS.split(" "); mvnArgs = mvnArgs.concat(addArgs); } // specVersion 1.4 doesn't support externalReferences.type=disribution-intake // so we need to run the plugin with the correct version if (options.specVersion === 1.4) { mvnArgs = mvnArgs.concat("-DschemaVersion=1.4"); } } const firstPom = pomFiles.length ? pomFiles[0] : undefined; let mavenCmd = getMavenCommand(path, path); for (const f of pomFiles) { const basePath = dirname(f); if ( isQuarkus && !options.deep && rootModules?.includes(basename(basePath)) ) { if (DEBUG_MODE) { console.log("Skipped sub-module", basePath); } continue; } const settingsXml = join(basePath, "settings.xml"); if (safeExistsSync(settingsXml)) { console.log( `maven settings.xml found in ${basePath}. Please set the MVN_ARGS environment variable based on the full mvn build command used for this project.\nExample: MVN_ARGS='--settings ${settingsXml}'`, ); } if (mavenCmd?.endsWith("mvn")) { mavenCmd = getMavenCommand(basePath, path); } // Should we attempt to resolve class names if (options.resolveClass || options.deep) { const tmpjarNSMapping = await collectMvnDependencies( mavenCmd, basePath, true, false, ); if (tmpjarNSMapping && Object.keys(tmpjarNSMapping).length) { jarNSMapping = { ...jarNSMapping, ...tmpjarNSMapping }; } } // Use the cyclonedx maven plugin if there is no preference for maven deps tree if (!useMavenDepsTree) { thoughtLog("The user wants me to use the cyclonedx-maven plugin."); console.log( `Executing '${mavenCmd} ${mvnArgs.join(" ")}' in`, basePath, ); result = spawnSync(mavenCmd, mvnArgs, { cwd: basePath, shell: true, encoding: "utf-8", timeout: TIMEOUT_MS, maxBuffer: MAX_BUFFER, }); // Check if the cyclonedx plugin created the required bom.json file // Sometimes the plugin fails silently for complex maven projects bomJsonFiles = getAllFiles( path, "**/target/*{cdx,bom,cyclonedx}*.json", options, ); // Check if the bom json files got created in a directory other than target if (!bomJsonFiles.length) { bomJsonFiles = getAllFiles( path, "target/**/*{cdx,bom,cyclonedx}*.json", options, ); } } // Also check if the user has a preference for maven deps tree command if ( useMavenDepsTree || !bomJsonFiles.length || result?.status !== 0 || result?.error ) { const tempDir = mkdtempSync(join(getTmpDir(), "cdxmvn-")); const tempMvnTree = join(tempDir, "mvn-tree.txt"); const tempMvnParentTree = join(tempDir, "mvn-parent-tree.txt"); let mvnTreeArgs = ["dependency:tree", `-DoutputFile=${tempMvnTree}`]; if (process.env.MVN_ARGS) { const addArgs = process.env.MVN_ARGS.split(" "); mvnTreeArgs = mvnTreeArgs.concat(addArgs); } // Automatically use settings.xml to improve the success for fallback if (safeExistsSync(settingsXml)) { mvnTreeArgs.push("-s"); mvnTreeArgs.push(settingsXml); } // For the first pom alone, we need to execute first in non-recursive mode to capture // the parent component. Then, we execute all of