Skip to content

Commit e2ff4f5

Browse files
authored
feat: support cyclonedx 1.6 (#424)
Signed-off-by: Ruben Romero Montes <[email protected]>
1 parent 319ddf4 commit e2ff4f5

File tree

7 files changed

+317
-76
lines changed

7 files changed

+317
-76
lines changed

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
<!-- Plugins -->
3535
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
3636
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
37-
<quarkus.platform.version>3.19.4</quarkus.platform.version>
37+
<quarkus.platform.version>3.23.2</quarkus.platform.version>
3838
<resources-plugin.version>3.3.1</resources-plugin.version>
3939
<build-helper-maven-plugin.version>3.4.0</build-helper-maven-plugin.version>
4040
<compiler-plugin.version>3.11.0</compiler-plugin.version>

src/main/java/com/redhat/exhort/integration/backend/sbom/cyclonedx/CycloneDxParser.java

Lines changed: 75 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,8 @@
2020

2121
import java.io.IOException;
2222
import java.io.InputStream;
23-
import java.util.Collections;
2423
import java.util.HashMap;
2524
import java.util.HashSet;
26-
import java.util.List;
2725
import java.util.Map;
2826
import java.util.Optional;
2927
import java.util.Set;
@@ -89,50 +87,83 @@ private Map<PackageRef, DirectDependency> buildDependencies(
8987
return buildUnknownDependencies(componentPurls);
9088
}
9189

92-
Map<PackageRef, List<PackageRef>> dependencies =
93-
bom.getDependencies().stream()
94-
.collect(
95-
Collectors.toMap(
96-
d -> {
97-
if (componentPurls.get(d.getRef()) == null) {
98-
return rootRef;
99-
}
100-
return componentPurls.get(d.getRef());
101-
},
102-
d -> {
103-
if (d.getDependencies() == null) {
104-
return Collections.emptyList();
105-
}
106-
return d.getDependencies().stream()
107-
.map(dep -> componentPurls.get(dep.getRef()))
108-
.toList();
109-
}));
110-
List<PackageRef> directDeps;
90+
Map<PackageRef, Set<PackageRef>> dependencies = new HashMap<>();
91+
bom.getDependencies()
92+
.forEach(
93+
d -> {
94+
PackageRef ref = componentPurls.getOrDefault(d.getRef(), rootRef);
95+
Set<PackageRef> deps = new HashSet<>();
96+
if (d.getDependencies() != null) {
97+
d.getDependencies()
98+
.forEach(
99+
dep -> {
100+
PackageRef depRef = componentPurls.get(dep.getRef());
101+
if (depRef != null) {
102+
deps.add(depRef);
103+
}
104+
});
105+
}
106+
dependencies.put(ref, deps);
107+
});
108+
111109
addUnknownDependencies(dependencies, componentPurls);
112-
if (rootRef != null && dependencies.get(rootRef) != null) {
113-
directDeps = dependencies.get(rootRef);
110+
111+
Set<PackageRef> directDeps;
112+
if (rootRef != null && dependencies.containsKey(rootRef)) {
113+
directDeps = new HashSet<>(dependencies.get(rootRef));
114114
} else {
115-
directDeps =
116-
dependencies.keySet().stream()
117-
.filter(depRef -> dependencies.values().stream().noneMatch(d -> d.contains(depRef)))
118-
.toList();
115+
directDeps = new HashSet<>(dependencies.keySet());
116+
dependencies.values().forEach(directDeps::removeAll);
119117
}
120118

121-
return directDeps.stream()
122-
.map(directRef -> toDirectDependency(directRef, dependencies))
123-
.collect(Collectors.toMap(DirectDependency::ref, d -> d));
119+
componentPurls.values().stream()
120+
.filter(Predicate.not(dependencies::containsKey))
121+
.forEach(directDeps::add);
122+
123+
Map<PackageRef, DirectDependency> result = new HashMap<>();
124+
directDeps.forEach(
125+
directRef -> {
126+
Set<PackageRef> transitiveRefs = new HashSet<>();
127+
findTransitiveIterative(directRef, dependencies, transitiveRefs);
128+
result.put(
129+
directRef,
130+
DirectDependency.builder().ref(directRef).transitive(transitiveRefs).build());
131+
});
132+
133+
return result;
124134
}
125135

126136
private void addUnknownDependencies(
127-
Map<PackageRef, List<PackageRef>> dependencies, Map<String, PackageRef> componentPurls) {
137+
Map<PackageRef, Set<PackageRef>> dependencies, Map<String, PackageRef> componentPurls) {
128138
Set<PackageRef> knownDeps = new HashSet<>(dependencies.keySet());
129-
dependencies.values().forEach(v -> knownDeps.addAll(v));
139+
dependencies.values().forEach(knownDeps::addAll);
130140
componentPurls.values().stream()
131141
.filter(Predicate.not(knownDeps::contains))
132-
.forEach(d -> dependencies.put(d, Collections.emptyList()));
142+
.forEach(d -> dependencies.put(d, new HashSet<>()));
143+
}
144+
145+
private void findTransitiveIterative(
146+
PackageRef startRef, Map<PackageRef, Set<PackageRef>> dependencies, Set<PackageRef> acc) {
147+
Set<PackageRef> toProcess = new HashSet<>();
148+
toProcess.add(startRef);
149+
150+
while (!toProcess.isEmpty()) {
151+
PackageRef current = toProcess.iterator().next();
152+
toProcess.remove(current);
153+
154+
Set<PackageRef> deps = dependencies.get(current);
155+
if (deps != null) {
156+
deps.stream()
157+
.filter(d -> !acc.contains(d))
158+
.forEach(
159+
d -> {
160+
acc.add(d);
161+
toProcess.add(d);
162+
});
163+
}
164+
}
133165
}
134166

135-
// The SBOM generator does not have info about the dependency hierarchy
136167
private Map<PackageRef, DirectDependency> buildUnknownDependencies(
137168
Map<String, PackageRef> componentPurls) {
138169
Map<PackageRef, DirectDependency> deps = new HashMap<>();
@@ -148,29 +179,6 @@ private Map<PackageRef, DirectDependency> buildUnknownDependencies(
148179
return deps;
149180
}
150181

151-
private DirectDependency toDirectDependency(
152-
PackageRef directRef, Map<PackageRef, List<PackageRef>> dependencies) {
153-
var transitiveRefs = new HashSet<PackageRef>();
154-
findTransitive(directRef, dependencies, transitiveRefs);
155-
var transitive = new HashSet<>(transitiveRefs.stream().toList());
156-
return DirectDependency.builder().ref(directRef).transitive(transitive).build();
157-
}
158-
159-
private void findTransitive(
160-
PackageRef ref, Map<PackageRef, List<PackageRef>> dependencies, Set<PackageRef> acc) {
161-
var deps = dependencies.get(ref);
162-
if (deps == null || deps.isEmpty()) {
163-
return;
164-
}
165-
deps.stream()
166-
.filter(d -> !acc.contains(d))
167-
.forEach(
168-
d -> {
169-
acc.add(d);
170-
findTransitive(d, dependencies, acc);
171-
});
172-
}
173-
174182
private Bom parseBom(InputStream input) {
175183
try {
176184
JsonNode node = MAPPER.readTree(input);
@@ -196,21 +204,15 @@ private Version parseSchemaVersion(String version) throws ParseException {
196204
if (version == null) {
197205
throw new ParseException("Missing CycloneDX Spec Version");
198206
}
199-
switch (version) {
200-
case "1.5":
201-
return Version.VERSION_15;
202-
case "1.4":
203-
return Version.VERSION_14;
204-
case "1.3":
205-
return Version.VERSION_13;
206-
case "1.2":
207-
return Version.VERSION_12;
208-
case "1.1":
209-
return Version.VERSION_11;
210-
case "1.0":
211-
return Version.VERSION_10;
212-
default:
213-
throw new ParseException("Invalid Spec Version received");
214-
}
207+
return switch (version) {
208+
case "1.6" -> Version.VERSION_16;
209+
case "1.5" -> Version.VERSION_15;
210+
case "1.4" -> Version.VERSION_14;
211+
case "1.3" -> Version.VERSION_13;
212+
case "1.2" -> Version.VERSION_12;
213+
case "1.1" -> Version.VERSION_11;
214+
case "1.0" -> Version.VERSION_10;
215+
default -> throw new ParseException("Invalid Spec Version received");
216+
};
215217
}
216218
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/*
2+
* Copyright 2025 Red Hat, Inc. and/or its affiliates
3+
* and other contributors as indicated by the @author tags.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
*
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
package com.redhat.exhort.integration.backend.sbom.cyclonedx;
20+
21+
import static org.junit.jupiter.api.Assertions.assertEquals;
22+
import static org.junit.jupiter.api.Assertions.assertNotNull;
23+
import static org.junit.jupiter.api.Assertions.assertThrows;
24+
import static org.junit.jupiter.api.Assertions.assertTrue;
25+
26+
import java.io.IOException;
27+
import java.io.InputStream;
28+
import java.nio.charset.StandardCharsets;
29+
import java.util.Map;
30+
import java.util.Set;
31+
32+
import org.junit.jupiter.api.Test;
33+
import org.junit.jupiter.params.ParameterizedTest;
34+
import org.junit.jupiter.params.provider.ValueSource;
35+
36+
import com.redhat.exhort.api.PackageRef;
37+
import com.redhat.exhort.config.exception.CycloneDXValidationException;
38+
import com.redhat.exhort.model.DependencyTree;
39+
import com.redhat.exhort.model.DirectDependency;
40+
41+
class CycloneDxParserTest {
42+
43+
private static final String TEST_RESOURCES_PATH = "/cyclonedx/";
44+
45+
@Test
46+
void testParseValidSbom() throws IOException {
47+
CycloneDxParser parser = new CycloneDxParser();
48+
try (InputStream input =
49+
getClass().getResourceAsStream(TEST_RESOURCES_PATH + "valid-1.6.json")) {
50+
assertNotNull(input, "Test resource not found");
51+
52+
DependencyTree tree = parser.buildTree(input);
53+
Map<PackageRef, DirectDependency> dependencies = tree.dependencies();
54+
55+
// Verify direct dependencies
56+
PackageRef dep1 = new PackageRef("pkg:maven/com.example/[email protected]");
57+
PackageRef dep2 = new PackageRef("pkg:maven/com.example/[email protected]");
58+
PackageRef transitive = new PackageRef("pkg:maven/com.example/[email protected]");
59+
60+
assertTrue(dependencies.containsKey(dep1));
61+
assertTrue(dependencies.containsKey(dep2));
62+
63+
// Verify transitive dependencies
64+
DirectDependency dep1Dependency = dependencies.get(dep1);
65+
Set<PackageRef> dep1Transitive = dep1Dependency.transitive();
66+
assertEquals(1, dep1Transitive.size());
67+
assertTrue(dep1Transitive.contains(transitive));
68+
69+
// Verify dep2 has no transitive dependencies
70+
DirectDependency dep2Dependency = dependencies.get(dep2);
71+
assertTrue(dep2Dependency.transitive().isEmpty());
72+
}
73+
}
74+
75+
@Test
76+
void testParseSbomWithUnknownDependencies() throws IOException {
77+
CycloneDxParser parser = new CycloneDxParser();
78+
try (InputStream input =
79+
getClass().getResourceAsStream(TEST_RESOURCES_PATH + "unknown-deps.json")) {
80+
assertNotNull(input, "Test resource not found");
81+
82+
DependencyTree tree = parser.buildTree(input);
83+
Map<PackageRef, DirectDependency> dependencies = tree.dependencies();
84+
85+
PackageRef known = new PackageRef("pkg:maven/com.example/[email protected]");
86+
PackageRef unknown = new PackageRef("pkg:maven/com.example/[email protected]");
87+
88+
// Verify both known and unknown dependencies are present
89+
assertTrue(dependencies.containsKey(known));
90+
assertTrue(dependencies.containsKey(unknown));
91+
92+
// Verify unknown dependency has no transitive dependencies
93+
DirectDependency unknownDependency = dependencies.get(unknown);
94+
assertTrue(unknownDependency.transitive().isEmpty());
95+
}
96+
}
97+
98+
@Test
99+
void testParseInvalidSbom() throws IOException {
100+
CycloneDxParser parser = new CycloneDxParser();
101+
try (InputStream input =
102+
getClass().getResourceAsStream(TEST_RESOURCES_PATH + "invalid-sbom.json")) {
103+
assertNotNull(input, "Test resource not found");
104+
var exception =
105+
assertThrows(CycloneDXValidationException.class, () -> parser.buildTree(input));
106+
assertNotNull(exception.getMessage());
107+
}
108+
}
109+
110+
@ParameterizedTest
111+
@ValueSource(strings = {"1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6"})
112+
void testSupportedVersions(String version) throws IOException {
113+
String resourcePath = TEST_RESOURCES_PATH + "empty-sbom.json";
114+
CycloneDxParser parser = new CycloneDxParser();
115+
try (InputStream input = getClass().getResourceAsStream(resourcePath)) {
116+
assertNotNull(input, "Test resource not found");
117+
118+
// Read the file content and replace the version
119+
String content =
120+
new String(input.readAllBytes(), StandardCharsets.UTF_8)
121+
.replace("\"specVersion\": \"1.6\"", "\"specVersion\": \"" + version + "\"");
122+
123+
// Create a new input stream with the modified content
124+
try (InputStream modifiedInput =
125+
new java.io.ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))) {
126+
DependencyTree tree = parser.buildTree(modifiedInput);
127+
assertNotNull(tree);
128+
assertTrue(tree.dependencies().isEmpty());
129+
}
130+
}
131+
}
132+
133+
@Test
134+
void testUnsupportedVersion() throws IOException {
135+
String resourcePath = TEST_RESOURCES_PATH + "empty-sbom.json";
136+
CycloneDxParser parser = new CycloneDxParser();
137+
try (InputStream input = getClass().getResourceAsStream(resourcePath)) {
138+
assertNotNull(input, "Test resource not found");
139+
140+
String content =
141+
new String(input.readAllBytes(), StandardCharsets.UTF_8)
142+
.replace("\"specVersion\" : \"1.6\"", "\"specVersion\": \"2.0\"");
143+
144+
try (InputStream modifiedInput =
145+
new java.io.ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))) {
146+
CycloneDXValidationException exception =
147+
assertThrows(CycloneDXValidationException.class, () -> parser.buildTree(modifiedInput));
148+
assertNotNull(exception.getMessage());
149+
}
150+
}
151+
}
152+
}

src/test/resources/application.properties

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ quarkus.log.category."com.github.tomakehurst".level=DEBUG
33
telemetry.disabled=true
44
api.ossindex.disabled=false
55
api.snyk.disabled=false
6-
quarkus.oidc-client.tpa.enabled=false
6+
quarkus.oidc-client.tpa.enabled=false
7+
quarkus.hibernate-orm.persistence-xml.ignore=true

src/test/resources/cyclonedx/empty-sbom.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"bomFormat" : "CycloneDX",
3-
"specVersion" : "1.4",
3+
"specVersion" : "1.6",
44
"serialNumber" : "urn:uuid:21e8d828-b07f-4fd3-bd14-0458f7188d40",
55
"version" : 1,
66
"metadata" : {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"bomFormat": "CycloneDX",
3+
"specVersion": "1.6",
4+
"version": 1,
5+
"components": [
6+
{
7+
"bom-ref": "pkg:maven/com.example/[email protected]",
8+
"type": "library",
9+
"name": "known",
10+
"version": "1.0.0",
11+
"purl": "pkg:maven/com.example/[email protected]"
12+
},
13+
{
14+
"bom-ref": "pkg:maven/com.example/[email protected]",
15+
"type": "library",
16+
"name": "unknown",
17+
"version": "1.0.0",
18+
"purl": "pkg:maven/com.example/[email protected]"
19+
}
20+
],
21+
"dependencies": [
22+
{
23+
"ref": "pkg:maven/com.example/[email protected]",
24+
"dependsOn": []
25+
}
26+
]
27+
}

0 commit comments

Comments
 (0)