diff --git a/plugins/dependency-checker/src/main/java/com/freenow/sauron/plugins/DependencyChecker.java b/plugins/dependency-checker/src/main/java/com/freenow/sauron/plugins/DependencyChecker.java index 9f706af..aa648ff 100644 --- a/plugins/dependency-checker/src/main/java/com/freenow/sauron/plugins/DependencyChecker.java +++ b/plugins/dependency-checker/src/main/java/com/freenow/sauron/plugins/DependencyChecker.java @@ -1,7 +1,9 @@ package com.freenow.sauron.plugins; import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.dataformat.xml.XmlMapper; import com.fasterxml.jackson.module.jaxb.JaxbAnnotationModule; import com.freenow.sauron.model.DataSet; @@ -21,6 +23,7 @@ import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.UUID; @Extension @Slf4j @@ -88,7 +91,24 @@ private List parseCycloneDxXml(Path bom) throws IOException private List parseCycloneDxJson(Path bom) throws IOException { ObjectMapper oMapper = new ObjectMapper(); - var bomContent = oMapper.readValue(bom.toFile(), Bom.class); - return Optional.ofNullable(bomContent.getComponents()).orElse(Collections.emptyList()); + JsonNode bomNode = oMapper.readTree(bom.toFile()); + + /* + * The npm BOM generator may produce an invalid serialNumber, which can cause validation issues in DependencyTrack. + * https://github.com/DependencyTrack/dependency-track/blob/fa1eb0bb4c1ecf87d231a21e077055acb6b8b59d/src/main/java/org/dependencytrack/parser/cyclonedx/CycloneDxValidator.java#L90 + * which returns error like this + * {"status":400,"title":"The uploaded BOM is invalid","detail":"Schema validation failed","errors":["$.serialNumber: does not match the regex pattern ^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"]} + * This code replaces the invalid serialNumber with a valid v4 UUID to ensure compatibility. + */ + if (bomNode.has("serialNumber") && bomNode.get("serialNumber").asText().contains("***")) + { + log.debug("Replacing invalid serialNumber in {} for project: {}", bom.getFileName(), bom.getParent()); + ((ObjectNode) bomNode).put("serialNumber", "urn:uuid:" + UUID.randomUUID()); + oMapper.writeValue(bom.toFile(), bomNode); + } + + Bom bomObject = oMapper.treeToValue(bomNode, Bom.class); + + return Optional.ofNullable(bomObject.getComponents()).orElse(Collections.emptyList()); } } diff --git a/plugins/dependency-checker/src/test/java/com/freenow/sauron/plugins/DependencyCheckerTest.java b/plugins/dependency-checker/src/test/java/com/freenow/sauron/plugins/DependencyCheckerTest.java index 86b4945..29f5db8 100644 --- a/plugins/dependency-checker/src/test/java/com/freenow/sauron/plugins/DependencyCheckerTest.java +++ b/plugins/dependency-checker/src/test/java/com/freenow/sauron/plugins/DependencyCheckerTest.java @@ -3,6 +3,10 @@ import com.freenow.sauron.model.DataSet; import com.freenow.sauron.properties.PluginsConfigurationProperties; import com.github.tomakehurst.wiremock.junit.WireMockRule; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import org.cyclonedx.model.Component; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -14,6 +18,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -220,8 +225,7 @@ public void testDependencyCheckerNodeJs() throws IOException, URISyntaxException )) ); } - - + @Test public void testDependencyCheckerNodeJsYarnNotSupported() throws IOException, URISyntaxException { @@ -440,4 +444,102 @@ private PluginsConfigurationProperties pluginConfigurationProperties() return properties; } + + @Test + public void testParseCycloneDxJsonWithInvalidSerialNumber() throws IOException, NoSuchMethodException, InvocationTargetException, IllegalAccessException + { + // Given + String invalidBomContent = "{\n" + + " \"bomFormat\": \"CycloneDX\",\n" + + " \"specVersion\": \"1.4\",\n" + + " \"serialNumber\": \"urn:uuid:***\",\n" + + " \"version\": 1,\n" + + " \"components\": [\n" + + " {\n" + + " \"type\": \"library\",\n" + + " \"name\": \"react\",\n" + + " \"version\": \"18.2.0\"\n" + + " }\n" + + " ]\n" + + "}"; + + Path bomJson = tempFolder.getRoot().toPath().resolve("bom.json"); + Files.write(bomJson, invalidBomContent.getBytes(StandardCharsets.UTF_8)); + + // When + List components = invokeParseCycloneDxJson(plugin, bomJson); + + // Then + assertNotNull(components); + assertEquals(1, components.size()); + assertEquals("react", components.get(0).getName()); + assertEquals("18.2.0", components.get(0).getVersion()); + + String sanitizedBomContent = new String(Files.readAllBytes(bomJson), StandardCharsets.UTF_8); + assertFalse("The serialNumber should have been sanitized", sanitizedBomContent.contains("***")); + } + + @Test + public void testParseCycloneDxJsonWithValidBom() throws IOException, NoSuchMethodException, InvocationTargetException, IllegalAccessException + { + // Given + String validBomContent = "{\n" + + " \"bomFormat\": \"CycloneDX\",\n" + + " \"specVersion\": \"1.4\",\n" + + " \"serialNumber\": \"urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79\",\n" + + " \"version\": 1,\n" + + " \"components\": [\n" + + " {\n" + + " \"type\": \"library\",\n" + + " \"name\": \"express\",\n" + + " \"version\": \"4.18.2\"\n" + + " }\n" + + " ]\n" + + "}"; + + Path bomJson = tempFolder.getRoot().toPath().resolve("bom.json"); + Files.write(bomJson, validBomContent.getBytes(StandardCharsets.UTF_8)); + + // When + List components = invokeParseCycloneDxJson(plugin, bomJson); + + // Then + assertNotNull(components); + assertEquals(1, components.size()); + assertEquals("express", components.get(0).getName()); + assertEquals("4.18.2", components.get(0).getVersion()); + + String bomContent = new String(Files.readAllBytes(bomJson), StandardCharsets.UTF_8); + assertEquals("The BOM file should not be modified", validBomContent, bomContent); + } + + @Test + public void testParseCycloneDxJsonWithNoComponents() throws IOException, NoSuchMethodException, InvocationTargetException, IllegalAccessException + { + // Given + String bomWithNoComponents = "{\n" + + " \"bomFormat\": \"CycloneDX\",\n" + + " \"specVersion\": \"1.4\",\n" + + " \"serialNumber\": \"urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79\",\n" + + " \"version\": 1,\n" + + " \"components\": []\n" + + "}"; + + Path bomJson = tempFolder.getRoot().toPath().resolve("bom.json"); + Files.write(bomJson, bomWithNoComponents.getBytes(StandardCharsets.UTF_8)); + + // When + List components = invokeParseCycloneDxJson(plugin, bomJson); + + // Then + assertNotNull(components); + assertTrue(components.isEmpty()); + } + + @SuppressWarnings("unchecked") + private List invokeParseCycloneDxJson(DependencyChecker plugin, Path bom) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + Method method = DependencyChecker.class.getDeclaredMethod("parseCycloneDxJson", Path.class); + method.setAccessible(true); + return (List) method.invoke(plugin, bom); + } } diff --git a/sauron-service/docker-compose.yml b/sauron-service/docker-compose.yml index f7a2786..a683f27 100644 --- a/sauron-service/docker-compose.yml +++ b/sauron-service/docker-compose.yml @@ -70,4 +70,4 @@ services: target: /root/.ssh read_only: true ports: - - "8080:8080" \ No newline at end of file + - "8080:8080"