com.metaeffekt.artifact.analysis.converter.CycloneDxBomToInventory Maven / Gradle / Ivy
/*
* Copyright 2021-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.metaeffekt.artifact.analysis.converter;
import com.github.packageurl.MalformedPackageURLException;
import com.github.packageurl.PackageURL;
import com.metaeffekt.artifact.analysis.utils.StringUtils;
import com.metaeffekt.artifact.analysis.vulnerability.CommonEnumerationUtil;
import com.metaeffekt.artifact.analysis.vulnerability.enrichment.InventoryAttribute;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.cyclonedx.exception.ParseException;
import org.cyclonedx.model.*;
import org.cyclonedx.parsers.BomParserFactory;
import org.cyclonedx.parsers.Parser;
import org.metaeffekt.core.inventory.processor.model.Artifact;
import org.metaeffekt.core.inventory.processor.model.ArtifactType;
import org.metaeffekt.core.inventory.processor.model.Constants;
import org.metaeffekt.core.inventory.processor.model.Inventory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import us.springett.parsers.cpe.Cpe;
import java.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* This class converts a given CycloneDx Bom into an Inventory.
* Attributes that are converted:
*
* - Name/Id
* - Component
* - Version
* - Group Id
* - Description
* - Cpe
* - Checksum
*
*/
public class CycloneDxBomToInventory {
private final static Logger LOG = LoggerFactory.getLogger(CycloneDxBomToInventory.class);
public CycloneDxBomToInventory() {
}
/**
* Boolean indicating whether the conversion process shoud include the metadata-level component.
*
* False by default for compatibility reasons.
*/
@Getter
@Setter
private boolean includeMetadataComponent = false;
/**
* Read a Bom from a JSON/XML file and convert it into an inventory.
*
* @param bomFile The file to convert.
* @return The converted inventory.
* @throws ParseException Thrown if the file is neither XML nor JSON.
*/
public Inventory convert(File bomFile) throws ParseException {
// create json or xml parser, detect from given file format
final Parser parser = BomParserFactory.createParser(bomFile);
final Bom parsedBom = parser.parse(bomFile);
return convert(parsedBom);
}
/**
* Convert a Bom directly into an inventory.
*
* @param bom The Bom to convert.
* @return The converted inventory.
*/
public Inventory convert(Bom bom) {
final Inventory inventory = new Inventory();
final List artifacts = inventory.getArtifacts();
String assetId = null;
if (includeMetadataComponent) {
final Metadata metadata = bom.getMetadata();
if (metadata != null) {
final Component metadataComponent = metadata.getComponent();
if (metadataComponent != null) {
final Artifact artifact = createArtifactFromComponent(metadataComponent);
artifacts.add(artifact);
assetId = "AID-" + artifact.getId();
// include md5 in assetId if available
final List hashes = metadataComponent.getHashes();
if (hashes != null) {
Optional md5Hash = hashes.stream().filter(h -> "MD5".equals(h.getAlgorithm())).findFirst();
if (md5Hash.isPresent()) {
assetId = "AID-" + artifact.getId() + "-" + md5Hash;
}
}
artifact.set(assetId, Constants.MARKER_CROSS);
}
}
}
final List components = Optional
.ofNullable(bom.getComponents())
.orElse(new ArrayList<>());
for (Component component : components) {
final Artifact artifact = createArtifactFromComponent(component);
if (assetId != null) {
artifact.set(assetId, Constants.MARKER_CONTAINS);
}
artifacts.add(artifact);
}
return inventory;
}
/**
* Converts the following properties from a {@link Component} into field values of an {@link Artifact}:
*
* -
* If present, the values from {@link Component#getProperties()} that are:
*
* - known attributes in {@link InventoryAttribute}
* URL
* Comment
*
*
* -
* If not set via the {@link Component#getProperties()}, the
* {@link InventoryAttribute#ADDITIONAL_CPE} from {@link Component#getCpe()}
*
* Id
from {@link Component#getName()}-{@link Component#getVersion()}
* Component
from {@link Component#getPublisher()}, or as a fallback {@link Component#getName()}
* Version
from {@link Component#getVersion()}
* Group Id
from {@link Component#getGroup()}
* Description
from {@link Component#getDescription()}
* Type
from {@link Component#getPurl()} or {@link Component#getType()}
* License
attempts to parse
* {@link Component#getLicenseChoice()}{@link LicenseChoice#getExpression() .getExpression()}
* -
* {@link Component#getHashes()} depending on algorithm:
*
* - MD5:
Checksum
* - other:
Checksum ($algorithm)
*
*
* -
* If the {@link Component#getPurl()} is set, and the following values are not yet set in the artifact,
* these values will be extracted:
*
* Ecosystem
from {@link PackageURL#getNamespace()}
* Id
from {@link PackageURL#getName()}
* Version
from {@link PackageURL#getVersion()}
* - different attributes based on the namespace
*
*
* -
* If the {@link Component#getSupplier()}is set:
*
* Organization
from {@link OrganizationalEntity#getName()}
* Organization URL
from {@link OrganizationalEntity#getUrls()}, joined as CSV
*
*
*
*
* @param component The component to create the artifact from.
* @return The converted artifact.
*/
private Artifact createArtifactFromComponent(Component component) {
final Artifact artifact = new Artifact();
final List properties = component.getProperties();
if (properties != null) {
for (InventoryAttribute value : InventoryAttribute.values()) {
transferPropertyAttributeToArtifactIfAvailable(artifact, value.getKey(), properties);
}
transferPropertyAttributeToArtifactIfAvailable(artifact, Artifact.Attribute.URL.getKey(), properties);
transferPropertyAttributeToArtifactIfAvailable(artifact, Artifact.Attribute.COMMENT.getKey(), properties);
}
// the CPE from the component may either be the only one listed, or there may be additional CPEs stored in the
// Additional CPE attribute of the artifact; therefore these need to be merged.
final Optional parsedFromComponent = CommonEnumerationUtil.parseCpe(component.getCpe());
if (parsedFromComponent.isPresent()) {
final List parsedFromArtifact = CommonEnumerationUtil.parseCpes(artifact.get(InventoryAttribute.ADDITIONAL_CPE.getKey()));
final List mergedCpes = CommonEnumerationUtil.distinctAndSortedWithWildcards(Collections.singletonList(parsedFromComponent.get()), parsedFromArtifact);
artifact.set(InventoryAttribute.ADDITIONAL_CPE.getKey(), CommonEnumerationUtil.toCpe22UriOrFallbackToCpe23FS(mergedCpes));
}
artifact.setId(component.getName());
artifact.setComponent(ObjectUtils.firstNonNull(component.getPublisher(), component.getName()));
artifact.setVersion(component.getVersion());
artifact.setGroupId(component.getGroup());
artifact.set("Description", component.getDescription());
// NOTE: the license information from CycloneDx does not match the semantics of any common attribute; therefore,
// we need to store the expression in a dedicated attribute
artifact.set("Component Specified License", convertLicense(component));
if (component.getHashes() != null) {
for (Hash hash : component.getHashes()) {
final String algorithm = hash.getAlgorithm();
final String value = hash.getValue();
if (algorithm.equals("MD5")) {
artifact.setChecksum(value);
} else {
artifact.set("Hash (" + algorithm + ")", value);
}
}
}
final String purl = component.getPurl();
if (purl != null) {
artifact.set(InventoryAttribute.PURL.getKey(), purl);
// parse purl and fill missing values in artifact
// scheme:type/namespace/name@version?qualifiers#subpath
try {
PackageURL packageURL = new PackageURL(purl);
if (!StringUtils.hasText(artifact.getId())) artifact.setId(packageURL.getName());
artifact.setComponent(packageURL.getName());
if (!StringUtils.hasText(artifact.getVersion())) artifact.setVersion(packageURL.getVersion());
extractEcosystemInformationFromPurlOntoArtifact(packageURL, artifact);
} catch (MalformedPackageURLException e) {
LOG.warn("Could not parse PURL [{}]", purl);
}
}
final String name = artifact.getId();
final String componentValue = artifact.getComponent();
final String version = artifact.getVersion();
if (shouldVersionBeAppendedToName(name, version)) {
artifact.setId(name + "-" + version);
} else if (name != null && name.equals(componentValue)) {
artifact.setComponent(removeVersionFromName(name, version));
}
final OrganizationalEntity supplier = component.getSupplier();
if (supplier != null) {
artifact.set(Constants.KEY_ORGANIZATION, supplier.getName());
artifact.set(Constants.KEY_ORGANIZATION_URL, String.join(", ", supplier.getUrls()));
}
// find type information from either purl or component type
final String componentType = component.getType().getTypeName();
final String purlType = artifact.get(InventoryAttribute.ECOSYSTEM);
final Pair derivedArtifactTypeInformation = deriveArtifactTypeFromComponentInformation(componentType, purlType);
if (derivedArtifactTypeInformation.getLeft() != null) {
artifact.set(Artifact.Attribute.TYPE, derivedArtifactTypeInformation.getLeft());
}
if (derivedArtifactTypeInformation.getRight() != null) {
artifact.set(Artifact.Attribute.COMPONENT_SOURCE_TYPE, derivedArtifactTypeInformation.getRight());
}
// FIXME: support dependency tree by setting markers
return artifact;
}
private Pair deriveArtifactTypeFromComponentInformation(String componentType, String purlType) {
if (StringUtils.hasText(componentType)) {
switch (componentType) {
case "operating-system":
return Pair.of(ArtifactType.OPERATING_SYSTEM.getCategory(), null);
case "device":
return Pair.of(ArtifactType.CATEGORY_HARDWARE.getCategory(), null);
case "device-driver":
return Pair.of(ArtifactType.DRIVER.getCategory(), null);
case "firmware":
return Pair.of(ArtifactType.BIOS.getCategory(), null);
case "file":
return Pair.of(ArtifactType.FILE.getCategory(), null);
}
}
// FIXME: When Component Pattern Source and Artifact Type redefinition is complete, rewrite usage of these types here.
// https://metaeffekt.atlassian.net/wiki/spaces/~712020304208f6c53c4d029d9adfab7c0a832f/pages/2975727617/Inventory+Attributes
if (StringUtils.hasText(purlType)) {
switch (purlType) {
case "maven":
return Pair.of("module", "jar-module");
case "npm":
return Pair.of(ArtifactType.WEB_MODULE.getCategory(), null);
case "nuget":
return Pair.of("module", "nuget-module");
case "pypi":
return Pair.of(ArtifactType.PYTHON_MODULE.getCategory(), null);
case "deb":
case "rpm":
case "apk":
case "portage":
case "opkg":
case "swid":
return Pair.of(ArtifactType.LINUX_PACKAGE.getCategory(), null);
}
}
return Pair.of(null, null);
}
private boolean shouldVersionBeAppendedToName(String name, String version) {
if (StringUtils.isEmpty(version)) {
return false;
}
if (version.equals("*")) {
return false;
}
if (StringUtils.isEmpty(name)) {
return false;
}
if (name.endsWith(".jar")) {
return false;
}
// the name might already contain the version string as an exact match
if (name.contains(version.split(" ")[0])) {
return false;
}
return true;
}
private String removeVersionFromName(String name, String version) {
if (StringUtils.isEmpty(name) || StringUtils.isEmpty(version)) {
return name;
}
version = "-" + version;
// try to find full version, then only the part before the first space
if (name.contains(version)) {
return name.replace(version, "").trim();
} else if (version.contains(" ")) {
return name.replace(version.split(" ")[0], "").trim();
}
return name;
}
private void extractEcosystemInformationFromPurlOntoArtifact(PackageURL packageURL, Artifact artifact) {
final String type = packageURL.getType();
if (artifact.get(InventoryAttribute.ECOSYSTEM) == null) {
artifact.set(InventoryAttribute.ECOSYSTEM, type);
}
final Optional optionalPackageUrlType = PackageUrlTypes.fromType(type);
if (optionalPackageUrlType.isPresent()) {
final PackageUrlTypes packageUrlType = optionalPackageUrlType.get();
if (packageUrlType == PackageUrlTypes.MAVEN || packageUrlType == PackageUrlTypes.BITBUCKET) {
if (!StringUtils.hasText(artifact.getGroupId())) {
artifact.setGroupId(packageURL.getNamespace());
}
}
}
}
private void transferPropertyAttributeToArtifactIfAvailable(Artifact artifact, String key, List properties) {
for (Property property : properties) {
if (property.getName() != null && property.getName().equals(key)) {
artifact.set(key, property.getValue());
return;
}
}
}
private String convertLicense(Component component) {
final LicenseChoice licenses = component.getLicenses();
if (licenses != null) {
// "EITHER (list of SPDX licenses and/or named licenses) OR (tuple of one SPDX License Expression)"
if (licenses.getExpression() != null) {
return licenses.getExpression().getValue();
}
final List licenseList = licenses.getLicenses();
if (licenseList != null) {
return licenseList.stream().map(l -> licenseToName(l)).collect(Collectors.joining(", "));
}
}
// NOTE: currently "licensing" is not supported
return null;
}
private String licenseToName(License l) {
if (l.getId() != null) return l.getId();
return l.getName();
}
@Getter
@AllArgsConstructor
public enum PackageUrlTypes {
DEBIAN("deb"),
PYPI("pypi"),
NPM("npm"),
MAVEN("maven"),
NUGET("nuget"),
RPM("rpm"),
GOLANG("golang"),
CARGO("cargo"),
GEM("gem"),
GITHUB("github"),
BITBUCKET("bitbucket");
private final String type;
/**
* Returns the {@link PackageUrlTypes} instance matching the given type string.
*
* @param type The type string to match.
* @return The matching {@link PackageUrlTypes} instance, if any.
*/
public static Optional fromType(String type) {
for (PackageUrlTypes packageUrlType : values()) {
if (packageUrlType.getType().equals(type)) {
return Optional.of(packageUrlType);
}
}
return Optional.empty();
}
}
}