@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
1,707 lines (1,678 loc) • 276 kB
JavaScript
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