From 570dfb5df89babe37e5190343bd7545979329b97 Mon Sep 17 00:00:00 2001 From: Richard DiCroce Date: Tue, 11 Apr 2023 11:27:02 -0400 Subject: [PATCH] Implement hierarchical merge utility Signed-off-by: Richard DiCroce --- .gitignore | 6 + src/main/java/org/cyclonedx/model/Bom.java | 14 ++ .../org/cyclonedx/model/BomReference.java | 4 + .../java/org/cyclonedx/util/BomMerger.java | 193 ++++++++++++++++++ .../org/cyclonedx/util/BomMergerTest.java | 185 +++++++++++++++++ src/test/resources/merge/components-test.json | 80 ++++++++ .../resources/merge/vulnerabilities-test.json | 52 +++++ 7 files changed, 534 insertions(+) create mode 100644 src/main/java/org/cyclonedx/util/BomMerger.java create mode 100644 src/test/java/org/cyclonedx/util/BomMergerTest.java create mode 100644 src/test/resources/merge/components-test.json create mode 100644 src/test/resources/merge/vulnerabilities-test.json diff --git a/.gitignore b/.gitignore index 1bbbc2b1f..e327aa6c0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,9 @@ target .idea/ dependency-reduced-pom.xml .DS_Store +/bin/ + +# Eclipse +.settings/ +.classpath +.project diff --git a/src/main/java/org/cyclonedx/model/Bom.java b/src/main/java/org/cyclonedx/model/Bom.java index 5f9493513..87a1acc1a 100644 --- a/src/main/java/org/cyclonedx/model/Bom.java +++ b/src/main/java/org/cyclonedx/model/Bom.java @@ -183,12 +183,26 @@ public void setCompositions(List compositions) { this.compositions = compositions; } + public void addComposition(Composition composition) { + if (compositions == null) { + compositions = new ArrayList<>(); + } + compositions.add(composition); + } + @JacksonXmlElementWrapper(localName = "vulnerabilities") @JacksonXmlProperty(localName = "vulnerability") public List getVulnerabilities() { return vulnerabilities; } public void setVulnerabilities(List vulnerabilities) { this.vulnerabilities = vulnerabilities; } + public void addVulnerability(Vulnerability vulnerability) { + if (vulnerabilities == null) { + vulnerabilities = new ArrayList<>(); + } + vulnerabilities.add(vulnerability); + } + @JacksonXmlElementWrapper(localName = "properties") @JacksonXmlProperty(localName = "property") public List getProperties() { diff --git a/src/main/java/org/cyclonedx/model/BomReference.java b/src/main/java/org/cyclonedx/model/BomReference.java index 1d8b700bf..827a4a115 100644 --- a/src/main/java/org/cyclonedx/model/BomReference.java +++ b/src/main/java/org/cyclonedx/model/BomReference.java @@ -41,6 +41,10 @@ public String getRef() { return ref; } + public void setRef(String ref) { + this.ref = ref; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/src/main/java/org/cyclonedx/util/BomMerger.java b/src/main/java/org/cyclonedx/util/BomMerger.java new file mode 100644 index 000000000..65275cbf4 --- /dev/null +++ b/src/main/java/org/cyclonedx/util/BomMerger.java @@ -0,0 +1,193 @@ +/* + * This file is part of CycloneDX Core (Java). + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.cyclonedx.util; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; +import org.cyclonedx.model.Bom; +import org.cyclonedx.model.BomReference; +import org.cyclonedx.model.Component; +import org.cyclonedx.model.Composition; +import org.cyclonedx.model.Dependency; +import org.cyclonedx.model.ExternalReference; +import org.cyclonedx.model.Metadata; +import org.cyclonedx.model.Service; +import org.cyclonedx.model.Tool; +import org.cyclonedx.model.vulnerability.Vulnerability; +import org.cyclonedx.model.vulnerability.Vulnerability.Affect; + +public class BomMerger { + + public Bom hierarchicalMerge(Iterable boms, Component bomSubject) { + Bom result = new Bom(); + Metadata resultMetadata = new Metadata(); + result.setMetadata(resultMetadata); + + List bomSubjectDependencies = new ArrayList<>(); + + if (bomSubject != null) { + resultMetadata.setComponent(bomSubject); + if (bomSubject.getBomRef() == null) { + bomSubject.setBomRef(componentBomRefNamespace(bomSubject)); + } + + Dependency topLevelDependency = new Dependency(bomSubject.getBomRef()); + topLevelDependency.setDependencies(bomSubjectDependencies); + result.addDependency(topLevelDependency); + } + + for (Bom bom : boms) { + Metadata bomMetadata = bom.getMetadata(); + Component bomComponent = bomMetadata == null ? null : bomMetadata.getComponent(); + if (bomComponent == null) { + throw new IllegalArgumentException("Required metadata (top level) component is missing from BOM."); + } + + List bomTools = bomMetadata.getTools(); + if (bomTools != null) { + bomTools.forEach(resultMetadata::addTool); + } + + List bomComponents = bom.getComponents(); + if (bomComponents != null) { + bomComponents.forEach(bomComponent::addComponent); + } + + String bomRefNamespace = componentBomRefNamespace(bomComponent); + + namespaceComponentBomRefs(bomRefNamespace, bomComponent); + + if (bomComponent.getBomRef() == null) { + bomComponent.setBomRef(bomRefNamespace); + } + + bomSubjectDependencies.add(new Dependency(bomComponent.getBomRef())); + + result.addComponent(bomComponent); + + List bomServices = bom.getServices(); + if (bomServices != null) { + for (Service service : bomServices) { + service.setBomRef(namespacedBomRef(bomRefNamespace, service.getBomRef())); + result.addService(service); + } + } + + List bomExternalRefs = bom.getExternalReferences(); + if (bomExternalRefs != null) { + bomExternalRefs.forEach(result::addExternalReference); + } + + List bomDependencies = bom.getDependencies(); + if (bomDependencies != null) { + namespaceDependencyBomRefs(bomRefNamespace, bomDependencies); + bomDependencies.forEach(result::addDependency); + } + + List bomCompositions = bom.getCompositions(); + if (bomCompositions != null) { + namespaceCompositions(bomRefNamespace, bomCompositions); + bomCompositions.forEach(result::addComposition); + } + + List bomVulnerabilities = bom.getVulnerabilities(); + if (bomVulnerabilities != null) { + namespaceVulnerabilityRefs(bomSubject.getBomRef(), bomVulnerabilities); + bomVulnerabilities.forEach(result::addVulnerability); + } + } + + return result; + } + + private String componentBomRefNamespace(Component component) { + StringBuilder builder = new StringBuilder(256); + String group = component.getGroup(); + if (group != null) { + builder.append(group).append('.'); + } + builder.append(component.getName()).append('@').append(component.getVersion()); + return builder.toString(); + } + + private String namespacedBomRef(String bomRefNamespace, String bomRef) { + return StringUtils.isEmpty(bomRef) ? null : bomRefNamespace + ":" + bomRef; + } + + private void namespaceBomRefs(String bomRefNamespace, List bomRefs) { + if (bomRefs != null) { + for (BomReference bomRef : bomRefs) { + bomRef.setRef(namespacedBomRef(bomRefNamespace, bomRef.getRef())); + } + } + } + + private void namespaceComponentBomRefs(String bomRefNamespace, Component topComponent) { + Deque components = new ArrayDeque<>(); + components.push(topComponent); + + while (!components.isEmpty()) { + Component currentComponent = components.pop(); + currentComponent.setBomRef(namespacedBomRef(bomRefNamespace, currentComponent.getBomRef())); + + List subComponents = currentComponent.getComponents(); + if (subComponents != null) { + subComponents.forEach(components::push); + } + } + } + + private void namespaceDependencyBomRefs(String bomRefNamespace, List dependencies) { + Deque pendingDependencies = new ArrayDeque<>(dependencies); + while (!pendingDependencies.isEmpty()) { + Dependency dependency = pendingDependencies.pop(); + dependency.setRef(namespacedBomRef(bomRefNamespace, dependency.getRef())); + + List subDependencies = dependency.getDependencies(); + if (subDependencies != null) { + subDependencies.forEach(pendingDependencies::push); + } + } + } + + private void namespaceCompositions(String bomRefNamespace, List compositions) { + for (Composition composition : compositions) { + namespaceBomRefs(bomRefNamespace, composition.getAssemblies()); + namespaceBomRefs(bomRefNamespace, composition.getDependencies()); + } + } + + private void namespaceVulnerabilityRefs(String bomRefNamespace, List vulnerabilities) { + for (Vulnerability vulnerability : vulnerabilities) { + vulnerability.setBomRef(namespacedBomRef(bomRefNamespace, vulnerability.getBomRef())); + + List affects = vulnerability.getAffects(); + if (affects != null) { + for (Affect affect : affects) { + affect.setRef(bomRefNamespace); + } + } + } + } + +} diff --git a/src/test/java/org/cyclonedx/util/BomMergerTest.java b/src/test/java/org/cyclonedx/util/BomMergerTest.java new file mode 100644 index 000000000..586102ac2 --- /dev/null +++ b/src/test/java/org/cyclonedx/util/BomMergerTest.java @@ -0,0 +1,185 @@ +/* + * This file is part of CycloneDX Core (Java). + * + * 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. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.cyclonedx.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.UUID; + +import org.cyclonedx.BomGeneratorFactory; +import org.cyclonedx.CycloneDxSchema.Version; +import org.cyclonedx.model.Bom; +import org.cyclonedx.model.BomReference; +import org.cyclonedx.model.Component; +import org.cyclonedx.model.Composition; +import org.cyclonedx.model.Composition.Aggregate; +import org.cyclonedx.model.Dependency; +import org.cyclonedx.model.Metadata; +import org.cyclonedx.model.vulnerability.Vulnerability; +import org.cyclonedx.model.vulnerability.Vulnerability.Affect; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +public class BomMergerTest { + + @Test + public void hierarchicalMergeComponentsTest() throws Exception { + Component subject = new Component(); + subject.setName("Thing"); + subject.setVersion("1"); + + Component sbom1System1 = new Component(); + sbom1System1.setName("System1"); + sbom1System1.setVersion("1"); + sbom1System1.setBomRef("System1@1"); + + Metadata sbom1Metadata = new Metadata(); + sbom1Metadata.setComponent(sbom1System1); + + Component sbom1Component1 = new Component(); + sbom1Component1.setName("Component1"); + sbom1Component1.setVersion("1"); + sbom1Component1.setBomRef("Component1@1"); + + Dependency sbom1Dependency1 = new Dependency("System1@1"); + sbom1Dependency1.addDependency(new Dependency("Component1@1")); + + Composition sbom1Composition1 = new Composition(); + sbom1Composition1.setAggregate(Aggregate.COMPLETE); + sbom1Composition1.addAssembly(new BomReference("System1@1")); + sbom1Composition1.addDependency(new BomReference("System1@1")); + + Bom sbom1 = new Bom(); + sbom1.setSerialNumber("urn:uuid:" + UUID.randomUUID()); + sbom1.setMetadata(sbom1Metadata); + sbom1.addComponent(sbom1Component1); + sbom1.addDependency(sbom1Dependency1); + sbom1.addComposition(sbom1Composition1); + + Component sbom2System2 = new Component(); + sbom2System2.setName("System2"); + sbom2System2.setVersion("1"); + sbom2System2.setBomRef("System2@1"); + + Metadata sbom2Metadata = new Metadata(); + sbom2Metadata.setComponent(sbom2System2); + + Component sbom2Component2 = new Component(); + sbom2Component2.setName("Component2"); + sbom2Component2.setVersion("1"); + sbom2Component2.setBomRef("Component2@1"); + + Dependency sbom2Dependency1 = new Dependency("System2@1"); + sbom2Dependency1.addDependency(new Dependency("Component2@1")); + + Composition sbom2Composition1 = new Composition(); + sbom2Composition1.setAggregate(Aggregate.COMPLETE); + sbom2Composition1.addAssembly(new BomReference("System2@1")); + sbom2Composition1.addDependency(new BomReference("System2@1")); + + Bom sbom2 = new Bom(); + sbom2.setSerialNumber("urn:uuid:" + UUID.randomUUID()); + sbom2.setMetadata(sbom2Metadata); + sbom2.addComponent(sbom2Component2); + sbom2.addDependency(sbom2Dependency1); + sbom2.addComposition(sbom2Composition1); + + Bom result = new BomMerger().hierarchicalMerge(Arrays.asList(sbom1, sbom2), subject); + //System.out.println(BomGeneratorFactory.createJson(Version.VERSION_14, result).toJsonString()); + + JsonNode actualResult = BomGeneratorFactory.createJson(Version.VERSION_14, result).toJsonNode(); + + JsonNode expectedResult = new ObjectMapper() + .readTree(getClass().getResourceAsStream("/merge/components-test.json")); + + assertBomsEqual(actualResult, expectedResult); + } + + @Test + public void hierarchicalMergeVulnerabilitiesTest() throws Exception { + Component subject = new Component(); + subject.setName("Thing"); + subject.setVersion("1"); + + Component sbom1System1 = new Component(); + sbom1System1.setName("System1"); + sbom1System1.setVersion("1"); + sbom1System1.setBomRef("System1@1"); + + Metadata sbom1Metadata = new Metadata(); + sbom1Metadata.setComponent(sbom1System1); + + Affect sbom1Affect1 = new Affect(); + sbom1Affect1.setRef("ref1"); + + Vulnerability sbom1Vulnerability1 = new Vulnerability(); + sbom1Vulnerability1.setId("cve1"); + sbom1Vulnerability1.setAffects(Arrays.asList(sbom1Affect1)); + + Bom sbom1 = new Bom(); + sbom1.setSerialNumber("urn:uuid:" + UUID.randomUUID()); + sbom1.setMetadata(sbom1Metadata); + sbom1.addVulnerability(sbom1Vulnerability1); + + Component sbom2System2 = new Component(); + sbom2System2.setName("System2"); + sbom2System2.setVersion("1"); + sbom2System2.setBomRef("System2@1"); + + Metadata sbom2Metadata = new Metadata(); + sbom2Metadata.setComponent(sbom2System2); + + Affect sbom2Affect1 = new Affect(); + sbom2Affect1.setRef("ref2"); + + Vulnerability sbom2Vulnerability1 = new Vulnerability(); + sbom2Vulnerability1.setId("cve2"); + sbom2Vulnerability1.setAffects(Arrays.asList(sbom2Affect1)); + + Bom sbom2 = new Bom(); + sbom2.setSerialNumber("urn:uuid:" + UUID.randomUUID()); + sbom2.setMetadata(sbom2Metadata); + sbom2.addVulnerability(sbom2Vulnerability1); + + Bom result = new BomMerger().hierarchicalMerge(Arrays.asList(sbom1, sbom2), subject); + //System.out.println(BomGeneratorFactory.createJson(Version.VERSION_14, result).toJsonString()); + + JsonNode actualResult = BomGeneratorFactory.createJson(Version.VERSION_14, result).toJsonNode(); + + JsonNode expectedResult = new ObjectMapper() + .readTree(getClass().getResourceAsStream("/merge/vulnerabilities-test.json")); + + assertBomsEqual(actualResult, expectedResult); + } + + private void assertBomsEqual(JsonNode actual, JsonNode expected) { + prepareBomForAssertion(actual); + prepareBomForAssertion(expected); + assertThat(actual).isEqualTo(expected); + } + + private void prepareBomForAssertion(JsonNode bomTree) { + ((ObjectNode) bomTree.path("metadata")).remove("timestamp"); + } + +} diff --git a/src/test/resources/merge/components-test.json b/src/test/resources/merge/components-test.json new file mode 100644 index 000000000..40d77d9b0 --- /dev/null +++ b/src/test/resources/merge/components-test.json @@ -0,0 +1,80 @@ +{ + "bomFormat" : "CycloneDX", + "specVersion" : "1.4", + "version" : 1, + "metadata" : { + "timestamp" : "2023-04-11T14:43:02Z", + "component" : { + "name" : "Thing", + "version" : "1", + "bom-ref" : "Thing@1" + } + }, + "components" : [ + { + "name" : "System1", + "version" : "1", + "components" : [ + { + "name" : "Component1", + "version" : "1", + "bom-ref" : "System1@1:Component1@1" + } + ], + "bom-ref" : "System1@1:System1@1" + }, + { + "name" : "System2", + "version" : "1", + "components" : [ + { + "name" : "Component2", + "version" : "1", + "bom-ref" : "System2@1:Component2@1" + } + ], + "bom-ref" : "System2@1:System2@1" + } + ], + "dependencies" : [ + { + "ref" : "Thing@1", + "dependsOn" : [ + "System1@1:System1@1", + "System2@1:System2@1" + ] + }, + { + "ref" : "System1@1:System1@1", + "dependsOn" : [ + "System1@1:Component1@1" + ] + }, + { + "ref" : "System2@1:System2@1", + "dependsOn" : [ + "System2@1:Component2@1" + ] + } + ], + "compositions" : [ + { + "aggregate" : "complete", + "assemblies" : [ + "System1@1:System1@1" + ], + "dependencies" : [ + "System1@1:System1@1" + ] + }, + { + "aggregate" : "complete", + "assemblies" : [ + "System2@1:System2@1" + ], + "dependencies" : [ + "System2@1:System2@1" + ] + } + ] +} diff --git a/src/test/resources/merge/vulnerabilities-test.json b/src/test/resources/merge/vulnerabilities-test.json new file mode 100644 index 000000000..67de5cd0e --- /dev/null +++ b/src/test/resources/merge/vulnerabilities-test.json @@ -0,0 +1,52 @@ +{ + "bomFormat" : "CycloneDX", + "specVersion" : "1.4", + "version" : 1, + "metadata" : { + "timestamp" : "2023-04-11T15:18:58Z", + "component" : { + "name" : "Thing", + "version" : "1", + "bom-ref" : "Thing@1" + } + }, + "components" : [ + { + "name" : "System1", + "version" : "1", + "bom-ref" : "System1@1:System1@1" + }, + { + "name" : "System2", + "version" : "1", + "bom-ref" : "System2@1:System2@1" + } + ], + "dependencies" : [ + { + "ref" : "Thing@1", + "dependsOn" : [ + "System1@1:System1@1", + "System2@1:System2@1" + ] + } + ], + "vulnerabilities" : [ + { + "id" : "cve1", + "affects" : [ + { + "ref" : "Thing@1" + } + ] + }, + { + "id" : "cve2", + "affects" : [ + { + "ref" : "Thing@1" + } + ] + } + ] +}