diff --git a/docs/_docs/usage/cicd.md b/docs/_docs/usage/cicd.md index 006872e44f..173b5a0285 100644 --- a/docs/_docs/usage/cicd.md +++ b/docs/_docs/usage/cicd.md @@ -73,3 +73,17 @@ curl -X "POST" "http://dtrack.example.com/api/v1/bom" \ -F "projectVersion=xxxx" \ -F "bom=@target/bom.xml" ``` + +You can also create a project as a child to some other project if you add `parentUUID` or `parentName` parameters. + +```bash +curl -X "POST" "http://dtrack.example.com/api/v1/bom" \ + -H 'Content-Type: multipart/form-data' \ + -H "X-Api-Key: xxxxxxx" \ + -F "autoCreate=true" \ + -F "projectName=xxxx" \ + -F "projectVersion=xxxx.SNAPSHOT" \ + -F "parentName=xxxx" \ + -F "parentVersion=xxxx" \ + -F "bom=@target/bom.xml" +``` diff --git a/pom.xml b/pom.xml index 7b5609d12b..f2a7ab8f98 100644 --- a/pom.xml +++ b/pom.xml @@ -86,13 +86,13 @@ 2.0.2 1.4.1 1.0.1 - 7.3.0 + 7.3.2 2.3.6 2.37.0 8.11.2 1.4.1 3.2.0 - 2.0.1 + 2.0.2 6.5.0 1.1.1 2.1.1 @@ -381,7 +381,7 @@ org.mock-server mockserver-netty - 5.14.0 + 5.15.0 test @@ -429,7 +429,7 @@ com.puppycrawl.tools checkstyle - 10.6.0 + 10.9.3 diff --git a/src/main/docker/Dockerfile b/src/main/docker/Dockerfile index 76274a8f4a..dfd0ae27cc 100644 --- a/src/main/docker/Dockerfile +++ b/src/main/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM eclipse-temurin:17.0.5_8-jre-focal@sha256:d98a588cd72194d040c83dad4eabed97c17677d592db7b964d31f12f9686dcbc AS jre-build +FROM eclipse-temurin:17.0.6_10-jre-focal@sha256:22942ca3ffac6e593063e33a225c458e315afa2c0dddfdbe15d337dd9130c70c AS jre-build FROM debian:bullseye-20230320-slim@sha256:7acda01e55b086181a6fa596941503648e423091ca563258e2c1657d140355b1 diff --git a/src/main/java/org/dependencytrack/model/Vulnerability.java b/src/main/java/org/dependencytrack/model/Vulnerability.java index cf56b3c8c4..9fca02e150 100644 --- a/src/main/java/org/dependencytrack/model/Vulnerability.java +++ b/src/main/java/org/dependencytrack/model/Vulnerability.java @@ -53,6 +53,7 @@ import java.io.Serializable; import java.math.BigDecimal; import java.util.ArrayList; +import java.util.Arrays; import java.util.Date; import java.util.List; import java.util.Objects; @@ -107,7 +108,11 @@ public enum Source { RETIREJS, // Retire.js INTERNAL, // Internally-managed (and manually entered) vulnerability OSV, // Google OSV Advisories - SNYK, // Snyk Purl Vulnerability + SNYK; // Snyk Purl Vulnerability + + public static boolean isKnownSource(String source) { + return Arrays.stream(values()).anyMatch(enumSource -> enumSource.name().equalsIgnoreCase(source)); + } } @PrimaryKey diff --git a/src/main/java/org/dependencytrack/parser/cyclonedx/CycloneDXVexImporter.java b/src/main/java/org/dependencytrack/parser/cyclonedx/CycloneDXVexImporter.java index ca469e205a..2fa8519086 100644 --- a/src/main/java/org/dependencytrack/parser/cyclonedx/CycloneDXVexImporter.java +++ b/src/main/java/org/dependencytrack/parser/cyclonedx/CycloneDXVexImporter.java @@ -42,17 +42,16 @@ public class CycloneDXVexImporter { public void applyVex(final QueryManager qm, final Bom bom, final Project project) { if (bom.getVulnerabilities() == null) return; - for (org.cyclonedx.model.vulnerability.Vulnerability cdxVuln: bom.getVulnerabilities()) { + List auditableVulnerabilities = bom.getVulnerabilities().stream().filter( + bomVuln -> bomVuln.getSource() == null || Vulnerability.Source.isKnownSource(bomVuln.getSource().getName()) + ).toList(); + for (org.cyclonedx.model.vulnerability.Vulnerability cdxVuln: auditableVulnerabilities) { if (cdxVuln.getAnalysis() == null) continue; final List vulns = qm.getVulnerabilities(project, true); if (vulns == null) continue; for (final Vulnerability vuln: vulns) { // NOTE: These vulnerability objects are detached - if ((vuln.getSource().equals(Vulnerability.Source.NVD.name()) - || vuln.getSource().equals(Vulnerability.Source.OSSINDEX.name()) - || vuln.getSource().equals(Vulnerability.Source.GITHUB.name()) - || vuln.getSource().equals(Vulnerability.Source.INTERNAL.name())) - && vuln.getVulnId().equals(cdxVuln.getId())) { + if (shouldAuditVulnerability(cdxVuln, vuln)) { if (cdxVuln.getAffects() == null) continue; for (org.cyclonedx.model.vulnerability.Vulnerability.Affect affect: cdxVuln.getAffects()) { @@ -81,6 +80,14 @@ public void applyVex(final QueryManager qm, final Bom bom, final Project project } } + private boolean shouldAuditVulnerability(org.cyclonedx.model.vulnerability.Vulnerability bomVulnerability, Vulnerability dtVulnerability) { + boolean result = true; + result = result && bomVulnerability.getSource() != null; + result = result && dtVulnerability.getVulnId().equals(bomVulnerability.getId()); + result = result && dtVulnerability.getSource().equalsIgnoreCase(bomVulnerability.getSource().getName()); + return result; + } + private void updateAnalysis(final QueryManager qm, final Component component, final Vulnerability vuln, final org.cyclonedx.model.vulnerability.Vulnerability cdxVuln) { // The vulnerability object is detached, so refresh it. diff --git a/src/main/java/org/dependencytrack/resources/v1/BomResource.java b/src/main/java/org/dependencytrack/resources/v1/BomResource.java index 636c7a2ce4..b770904145 100644 --- a/src/main/java/org/dependencytrack/resources/v1/BomResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/BomResource.java @@ -225,7 +225,29 @@ public Response uploadBom(BomSubmitRequest request) { Project project = qm.getProject(request.getProjectName(), request.getProjectVersion()); if (project == null && request.isAutoCreate()) { if (hasPermission(Permissions.Constants.PORTFOLIO_MANAGEMENT) || hasPermission(Permissions.Constants.PROJECT_CREATION_UPLOAD)) { - project = qm.createProject(StringUtils.trimToNull(request.getProjectName()), null, StringUtils.trimToNull(request.getProjectVersion()), null, null, null, true, true); + Project parent = null; + if (request.getParentUUID() != null || request.getParentName() != null) { + if (request.getParentUUID() != null) { + failOnValidationError(validator.validateProperty(request, "parentUUID")); + parent = qm.getObjectByUuid(Project.class, request.getParentUUID()); + } else { + failOnValidationError( + validator.validateProperty(request, "parentName"), + validator.validateProperty(request, "parentVersion") + ); + final String trimmedParentName = StringUtils.trimToNull(request.getParentName()); + final String trimmedParentVersion = StringUtils.trimToNull(request.getParentVersion()); + parent = qm.getProject(trimmedParentName, trimmedParentVersion); + } + + if (parent == null) { // if parent project is specified but not found + return Response.status(Response.Status.NOT_FOUND).entity("The parent component could not be found.").build(); + } else if (! qm.hasAccess(super.getPrincipal(), parent)) { + return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified parent project is forbidden").build(); + } + } + + project = qm.createProject(StringUtils.trimToNull(request.getProjectName()), null, StringUtils.trimToNull(request.getProjectVersion()), null, parent, null, true, true); Principal principal = getPrincipal(); qm.updateNewProjectACL(project, principal); } else { @@ -251,6 +273,9 @@ public Response uploadBom(@FormDataParam("project") String projectUuid, @DefaultValue("false") @FormDataParam("autoCreate") boolean autoCreate, @FormDataParam("projectName") String projectName, @FormDataParam("projectVersion") String projectVersion, + @FormDataParam("parentName") String parentName, + @FormDataParam("parentVersion") String parentVersion, + @FormDataParam("parentUUID") String parentUUID, final FormDataMultiPart multiPart) { final List artifactParts = multiPart.getFields("bom"); @@ -266,7 +291,24 @@ public Response uploadBom(@FormDataParam("project") String projectUuid, Project project = qm.getProject(trimmedProjectName, trimmedProjectVersion); if (project == null && autoCreate) { if (hasPermission(Permissions.Constants.PORTFOLIO_MANAGEMENT) || hasPermission(Permissions.Constants.PROJECT_CREATION_UPLOAD)) { - project = qm.createProject(trimmedProjectName, null, trimmedProjectVersion, null, null, null, true, true); + Project parent = null; + if (parentUUID != null || parentName != null) { + if (parentUUID != null) { + + parent = qm.getObjectByUuid(Project.class, parentUUID); + } else { + final String trimmedParentName = StringUtils.trimToNull(parentName); + final String trimmedParentVersion = StringUtils.trimToNull(parentVersion); + parent = qm.getProject(trimmedParentName, trimmedParentVersion); + } + + if (parent == null) { // if parent project is specified but not found + return Response.status(Response.Status.NOT_FOUND).entity("The parent component could not be found.").build(); + } else if (! qm.hasAccess(super.getPrincipal(), parent)) { + return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified parent project is forbidden").build(); + } + } + project = qm.createProject(trimmedProjectName, null, trimmedProjectVersion, null, parent, null, true, true); Principal principal = getPrincipal(); qm.updateNewProjectACL(project, principal); } else { diff --git a/src/main/java/org/dependencytrack/resources/v1/vo/BomSubmitRequest.java b/src/main/java/org/dependencytrack/resources/v1/vo/BomSubmitRequest.java index 4729e40ce7..7021bca27c 100644 --- a/src/main/java/org/dependencytrack/resources/v1/vo/BomSubmitRequest.java +++ b/src/main/java/org/dependencytrack/resources/v1/vo/BomSubmitRequest.java @@ -50,22 +50,47 @@ public final class BomSubmitRequest { @Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS, message = "The project version may only contain printable characters") private final String projectVersion; + @Pattern(regexp = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", message = "The parent UUID must be a valid 36 character UUID") + private final String parentUUID; + + @JsonDeserialize(using = TrimmedStringDeserializer.class) + @Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS, message = "The parent name may only contain printable characters") + private final String parentName; + + @JsonDeserialize(using = TrimmedStringDeserializer.class) + @Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS, message = "The parent version may only contain printable characters") + private final String parentVersion; + @NotNull @Pattern(regexp = "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$", message = "The BOM must be Base64 encoded") private final String bom; private final boolean autoCreate; + public BomSubmitRequest(String project, + String projectName, + String projectVersion, + boolean autoCreate, + String bom) { + this(project, projectName, projectVersion, autoCreate, null, null, null, bom); + } + @JsonCreator public BomSubmitRequest(@JsonProperty(value = "project", required = false) String project, @JsonProperty(value = "projectName", required = false) String projectName, @JsonProperty(value = "projectVersion", required = false) String projectVersion, @JsonProperty(value = "autoCreate", required = false) boolean autoCreate, + @JsonProperty(value = "parentUUID", required = false) String parentUUID, + @JsonProperty(value = "parentName", required = false) String parentName, + @JsonProperty(value = "parentVersion", required = false) String parentVersion, @JsonProperty(value = "bom", required = true) String bom) { this.project = project; this.projectName = projectName; this.projectVersion = projectVersion; this.autoCreate = autoCreate; + this.parentUUID = parentUUID; + this.parentName = parentName; + this.parentVersion = parentVersion; this.bom = bom; } @@ -81,6 +106,18 @@ public String getProjectVersion() { return projectVersion; } + public String getParentUUID() { + return parentUUID; + } + + public String getParentName() { + return parentName; + } + + public String getParentVersion() { + return parentVersion; + } + public boolean isAutoCreate() { return autoCreate; } diff --git a/src/main/resources/templates/notification/publisher/msteams.peb b/src/main/resources/templates/notification/publisher/msteams.peb index 238ab2f77d..08c585e08a 100644 --- a/src/main/resources/templates/notification/publisher/msteams.peb +++ b/src/main/resources/templates/notification/publisher/msteams.peb @@ -31,11 +31,11 @@ "facts": [ { "name": "Project", - "value": "{{ subject.dependency.project.toString | escape(strategy="json") }}" + "value": "{{ subject.component.project.toString | escape(strategy="json") }}" }, { "name": "Component", - "value": "{{ subject.dependency.component.toString | escape(strategy="json") }}" + "value": "{{ subject.component.toString | escape(strategy="json") }}" } ], {% elseif notification.group == "PROJECT_AUDIT_CHANGE" %} diff --git a/src/test/java/org/dependencytrack/parser/cyclonedx/CycloneDXVexImporterTest.java b/src/test/java/org/dependencytrack/parser/cyclonedx/CycloneDXVexImporterTest.java new file mode 100644 index 0000000000..82a956fdeb --- /dev/null +++ b/src/test/java/org/dependencytrack/parser/cyclonedx/CycloneDXVexImporterTest.java @@ -0,0 +1,131 @@ +package org.dependencytrack.parser.cyclonedx; + +import org.assertj.core.api.Assertions; +import org.cyclonedx.BomParserFactory; +import org.cyclonedx.exception.ParseException; +import org.dependencytrack.PersistenceCapableTest; +import org.dependencytrack.model.Analysis; +import org.dependencytrack.model.AnalysisJustification; +import org.dependencytrack.model.AnalysisState; +import org.dependencytrack.model.Component; +import org.dependencytrack.model.Severity; +import org.dependencytrack.model.Vulnerability; +import org.dependencytrack.tasks.scanners.AnalyzerIdentity; +import org.junit.Assert; +import org.junit.Test; + +import javax.jdo.Query; +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; +import java.util.UUID; + +public class CycloneDXVexImporterTest extends PersistenceCapableTest { + + private CycloneDXVexImporter vexImporter = new CycloneDXVexImporter(); + + @Test + public void shouldAuditVulnerabilityFromAllSourcesUsingVex() throws URISyntaxException, IOException, ParseException { + // Arrange + var sources = Arrays.asList(Vulnerability.Source.values()); + var project = qm.createProject("Acme Example", null, "1.0", null, null, null, true, false); + + var component = new Component(); + component.setProject(project); + component.setName("Acme Component"); + component.setVersion("1.0"); + component = qm.createComponent(component, false); + + final byte[] vexBytes = Files.readAllBytes(Paths.get(getClass().getClassLoader().getResource("vex-1.json").toURI())); + var parser = BomParserFactory.createParser(vexBytes); + var vex = parser.parse(vexBytes); + + List audits = new LinkedList<>(); + + var unknownVexSourceVulnerability = new Vulnerability(); + unknownVexSourceVulnerability.setVulnId("CVE-2020-25649"); + unknownVexSourceVulnerability.setSource(Vulnerability.Source.NVD); + unknownVexSourceVulnerability.setSeverity(Severity.HIGH); + unknownVexSourceVulnerability.setComponents(List.of(component)); + unknownVexSourceVulnerability = qm.createVulnerability(unknownVexSourceVulnerability, false); + qm.addVulnerability(unknownVexSourceVulnerability, component, AnalyzerIdentity.NONE); + + var mismatchVexSourceVulnerability = new Vulnerability(); + mismatchVexSourceVulnerability.setVulnId("CVE-2020-25650"); + mismatchVexSourceVulnerability.setSource(Vulnerability.Source.NVD); + mismatchVexSourceVulnerability.setSeverity(Severity.HIGH); + mismatchVexSourceVulnerability.setComponents(List.of(component)); + mismatchVexSourceVulnerability = qm.createVulnerability(mismatchVexSourceVulnerability, false); + qm.addVulnerability(mismatchVexSourceVulnerability, component, AnalyzerIdentity.NONE); + + var noVexSourceVulnerability = new Vulnerability(); + noVexSourceVulnerability.setVulnId("CVE-2020-25651"); + noVexSourceVulnerability.setSource(Vulnerability.Source.GITHUB); + noVexSourceVulnerability.setSeverity(Severity.HIGH); + noVexSourceVulnerability.setComponents(List.of(component)); + noVexSourceVulnerability = qm.createVulnerability(noVexSourceVulnerability, false); + qm.addVulnerability(noVexSourceVulnerability, component, AnalyzerIdentity.NONE); + + // Build vulnerabilities for each available and known vulnerability source + for (var source : sources) { + var vulnId = source.name().toUpperCase()+"-001"; + var vulnerability = new Vulnerability(); + vulnerability.setVulnId(vulnId); + vulnerability.setSource(source); + vulnerability.setSeverity(Severity.HIGH); + vulnerability.setComponents(List.of(component)); + vulnerability = qm.createVulnerability(vulnerability, false); + qm.addVulnerability(vulnerability, component, AnalyzerIdentity.NONE); + + var audit = new org.cyclonedx.model.vulnerability.Vulnerability(); + audit.setBomRef(UUID.randomUUID().toString()); + audit.setId(vulnId); + var auditSource = new org.cyclonedx.model.vulnerability.Vulnerability.Source(); + auditSource.setName(source.name()); + audit.setSource(auditSource); + var analysis = new org.cyclonedx.model.vulnerability.Vulnerability.Analysis(); + analysis.setState(org.cyclonedx.model.vulnerability.Vulnerability.Analysis.State.FALSE_POSITIVE); + analysis.setDetail("Unit test"); + analysis.setJustification(org.cyclonedx.model.vulnerability.Vulnerability.Analysis.Justification.PROTECTED_BY_MITIGATING_CONTROL); + audit.setAnalysis(analysis); + var affect = new org.cyclonedx.model.vulnerability.Vulnerability.Affect(); + affect.setRef(vex.getMetadata().getComponent().getBomRef()); + audit.setAffects(List.of(affect)); + audits.add(audit); + } + audits.addAll(vex.getVulnerabilities()); + vex.setVulnerabilities(audits); + qm.getPersistenceManager().refreshAll(); + + // Act + vexImporter.applyVex(qm, vex, project); + + // Assert + final Query query = qm.getPersistenceManager().newQuery(Analysis.class, "project == :project"); + var analyses = (List) query.execute(project); + // CVE-2020-256[49|50|51] are not audited otherwise analyses.size would have been equal to sources.size()+3 + Assert.assertEquals(sources.size(), analyses.size()); + Assertions.assertThat(analyses).allSatisfy(analysis -> { + Assertions.assertThat(analysis.getVulnerability().getVulnId()).isNotEqualTo("CVE-2020-25649"); + Assertions.assertThat(analysis.getVulnerability().getVulnId()).isNotEqualTo("CVE-2020-25650"); + Assertions.assertThat(analysis.isSuppressed()).isTrue(); + Assertions.assertThat(analysis.getAnalysisComments().size()).isEqualTo(3); + Assertions.assertThat(analysis.getAnalysisComments()).satisfiesExactlyInAnyOrder(comment -> { + Assertions.assertThat(comment.getCommenter()).isEqualTo("CycloneDX VEX"); + Assertions.assertThat(comment.getComment()).isEqualTo(String.format("Analysis: %s → %s", AnalysisState.NOT_SET, AnalysisState.FALSE_POSITIVE)); + }, comment -> { + Assertions.assertThat(comment.getCommenter()).isEqualTo("CycloneDX VEX"); + Assertions.assertThat(comment.getComment()).isEqualTo("Details: Unit test"); + }, comment -> { + Assertions.assertThat(comment.getCommenter()).isEqualTo("CycloneDX VEX"); + Assertions.assertThat(comment.getComment()).isEqualTo(String.format("Justification: %s → %s", AnalysisJustification.NOT_SET, AnalysisJustification.PROTECTED_BY_MITIGATING_CONTROL)); + }); + Assertions.assertThat(analysis.getAnalysisDetails()).isEqualTo("Unit test"); + }); + } + +} diff --git a/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java index da60323eb8..11abdfc928 100644 --- a/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java @@ -657,4 +657,90 @@ public void uploadBomUnauthorizedTest() throws Exception { Assert.assertEquals("The principal does not have permission to create project.", body); } + @Test + public void uploadBomAutoCreateTestWithParentTest() throws Exception { + initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); + File file = new File(Thread.currentThread().getContextClassLoader().getResource("bom-1.xml").toURI()); + String bomString = Base64.getEncoder().encodeToString(FileUtils.readFileToByteArray(file)); + // Upload parent project + BomSubmitRequest request = new BomSubmitRequest(null, "Acme Parent", "1.0", true, bomString); + Response response = target(V1_BOM).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(request, MediaType.APPLICATION_JSON)); + Assert.assertEquals(200, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertNotNull(json); + Project parent = qm.getProject("Acme Parent", "1.0"); + Assert.assertNotNull(parent); + String parentUUID = parent.getUuid().toString(); + + // Upload first child, search parent by UUID + request = new BomSubmitRequest(null, "Acme Example", "1.0", true, parentUUID, null, null, bomString); + response = target(V1_BOM).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(request, MediaType.APPLICATION_JSON)); + Assert.assertEquals(200, response.getStatus(), 0); + json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertNotNull(json.getString("token")); + Assert.assertTrue(UuidUtil.isValidUUID(json.getString("token"))); + Project child = qm.getProject("Acme Example", "1.0"); + Assert.assertNotNull(child); + Assert.assertNotNull(child.getParent()); + Assert.assertEquals(parentUUID, child.getParent().getUuid().toString()); + + + // Upload second child, search parent by name+ver + request = new BomSubmitRequest(null, "Acme Example", "2.0", true, null, "Acme Parent", "1.0", bomString); + response = target(V1_BOM).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(request, MediaType.APPLICATION_JSON)); + Assert.assertEquals(200, response.getStatus(), 0); + json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertNotNull(json.getString("token")); + Assert.assertTrue(UuidUtil.isValidUUID(json.getString("token"))); + child = qm.getProject("Acme Example", "2.0"); + Assert.assertNotNull(child); + Assert.assertNotNull(child.getParent()); + Assert.assertEquals(parentUUID, child.getParent().getUuid().toString()); + + // Upload third child, specify parent's UUID, name, ver. Name and ver are ignored when UUID is specified. + request = new BomSubmitRequest(null, "Acme Example", "3.0", true, parentUUID, "Non-existent parent", "1.0", bomString); + response = target(V1_BOM).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(request, MediaType.APPLICATION_JSON)); + Assert.assertEquals(200, response.getStatus(), 0); + json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertNotNull(json.getString("token")); + Assert.assertTrue(UuidUtil.isValidUUID(json.getString("token"))); + child = qm.getProject("Acme Example", "3.0"); + Assert.assertNotNull(child); + Assert.assertNotNull(child.getParent()); + Assert.assertEquals(parentUUID, child.getParent().getUuid().toString()); + } + + @Test + public void uploadBomInvalidParentTest() throws Exception { + initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); + File file = new File(Thread.currentThread().getContextClassLoader().getResource("bom-1.xml").toURI()); + String bomString = Base64.getEncoder().encodeToString(FileUtils.readFileToByteArray(file)); + BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", true, UUID.randomUUID().toString(), null, null, bomString); + Response response = target(V1_BOM).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(request, MediaType.APPLICATION_JSON)); + Assert.assertEquals(404, response.getStatus(), 0); + String body = getPlainTextBody(response); + Assert.assertEquals("The parent component could not be found.", body); + + request = new BomSubmitRequest(null, "Acme Example", "2.0", true, null, "Non-existent parent", null, bomString); + response = target(V1_BOM).request() + .header(X_API_KEY, apiKey) + .put(Entity.entity(request, MediaType.APPLICATION_JSON)); + Assert.assertEquals(404, response.getStatus(), 0); + body = getPlainTextBody(response); + Assert.assertEquals("The parent component could not be found.", body); + } + } diff --git a/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java b/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java index 49869902db..55c866faec 100644 --- a/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java +++ b/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java @@ -20,6 +20,7 @@ import alpine.event.framework.EventService; import alpine.notification.Notification; +import alpine.notification.NotificationLevel; import alpine.notification.NotificationService; import alpine.notification.Subscriber; import alpine.notification.Subscription; @@ -28,21 +29,26 @@ import org.dependencytrack.event.BomUploadEvent; import org.dependencytrack.event.NewVulnerableDependencyAnalysisEvent; import org.dependencytrack.event.VulnerabilityAnalysisEvent; -import org.dependencytrack.model.Severity; -import org.dependencytrack.model.Vulnerability; -import org.dependencytrack.model.VulnerableSoftware; +import org.dependencytrack.model.Bom; +import org.dependencytrack.model.Classifier; +import org.dependencytrack.model.Component; import org.dependencytrack.model.ConfigPropertyConstants; import org.dependencytrack.model.Project; -import org.dependencytrack.model.Component; -import org.dependencytrack.model.Classifier; +import org.dependencytrack.model.Severity; +import org.dependencytrack.model.Vulnerability; import org.dependencytrack.model.VulnerabilityAnalysisLevel; +import org.dependencytrack.model.VulnerableSoftware; import org.dependencytrack.notification.NotificationGroup; +import org.dependencytrack.notification.NotificationScope; +import org.dependencytrack.notification.vo.BomProcessingFailed; import org.dependencytrack.notification.vo.NewVulnerabilityIdentified; +import org.junit.After; import org.junit.AfterClass; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.time.Duration; @@ -95,6 +101,7 @@ public void setUp() { ConfigPropertyConstants.SCANNER_INTERNAL_ENABLED.getDescription()); } + @After public void tearDown() { NOTIFICATIONS.clear(); } @@ -167,4 +174,39 @@ public void informTest() throws Exception { ); } + @Test + public void informWithInvalidCycloneDxBomTest() throws Exception { + final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, true, false); + + final byte[] bomBytes = """ + { + "bomFormat": "CycloneDX", + """.getBytes(StandardCharsets.UTF_8); + + new BomUploadProcessingTask().inform(new BomUploadEvent(project.getUuid(), bomBytes)); + assertConditionWithTimeout(() -> NOTIFICATIONS.size() >= 2, Duration.ofSeconds(5)); + + assertThat(NOTIFICATIONS).satisfiesExactly( + notification -> assertThat(notification.getGroup()).isEqualTo(NotificationGroup.PROJECT_CREATED.name()), + notification -> { + assertThat(notification.getScope()).isEqualTo(NotificationScope.PORTFOLIO.name()); + assertThat(notification.getGroup()).isEqualTo(NotificationGroup.BOM_PROCESSING_FAILED.name()); + assertThat(notification.getLevel()).isEqualTo(NotificationLevel.ERROR); + assertThat(notification.getTitle()).isNotBlank(); + assertThat(notification.getContent()).isNotBlank(); + assertThat(notification.getSubject()).isInstanceOf(BomProcessingFailed.class); + final var subject = (BomProcessingFailed) notification.getSubject(); + assertThat(subject.getProject().getUuid()).isEqualTo(project.getUuid()); + assertThat(subject.getBom()).isEqualTo("ewogICJib21Gb3JtYXQiOiAiQ3ljbG9uZURYIiwK"); + assertThat(subject.getFormat()).isEqualTo(Bom.Format.CYCLONEDX); + assertThat(subject.getSpecVersion()).isNull(); + assertThat(subject.getCause()).isEqualTo("Unable to parse BOM from byte array"); + } + ); + + qm.getPersistenceManager().refresh(project); + assertThat(project.getClassifier()).isNull(); + assertThat(project.getLastBomImport()).isNull(); + } + } diff --git a/src/test/resources/vex-1.json b/src/test/resources/vex-1.json new file mode 100644 index 0000000000..807fdacccb --- /dev/null +++ b/src/test/resources/vex-1.json @@ -0,0 +1,99 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "serialNumber": "urn:uuid:50f729f5-1e3c-4d10-b0da-d2f1c02ca257", + "version": 1, + "metadata": { + "timestamp": "2023-03-01T00:00:00Z", + "tools": [ + { + "vendor": "OWASP", + "name": "Dependency-Track", + "version": "latest" + } + ], + "component": { + "name": "Acme example", + "version": "1.0", + "type": "application", + "bom-ref": "7f2ee811-6b35-4c24-83ec-605d7939005c" + } + }, + "vulnerabilities": [ + { + "id": "CVE-2020-25649", + "source": { + "name": "National Vulnerability Database", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2020-25649" + }, + "ratings": [ + { + "source": { + "name": "NVD", + "url": "https://nvd.nist.gov/vuln-metrics/cvss/v3-calculator?vector=AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N&version=3.1" + }, + "score": 7.5, + "severity": "high", + "method": "CVSSv31", + "vector": "AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N" + } + ], + "analysis": { + "state": "not_affected" + }, + "affects": [ + { + "ref": "7f2ee811-6b35-4c24-83ec-605d7939005c" + } + ] + }, + { + "id": "CVE-2020-25650", + "source": { + "name": "OSSINDEX", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2020-25650" + }, + "ratings": [ + { + "source": { + "name": "OSSINDEX" + }, + "score": 7.5, + "severity": "high", + "method": "CVSSv31", + "vector": "AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N" + } + ], + "analysis": { + "state": "false_positive" + }, + "affects": [ + { + "ref": "7f2ee811-6b35-4c24-83ec-605d7939005c" + } + ] + }, + { + "id": "CVE-2020-25651", + "ratings": [ + { + "source": { + "name": "OSSINDEX" + }, + "score": 7.6, + "severity": "high", + "method": "CVSSv31", + "vector": "AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N" + } + ], + "analysis": { + "state": "false_positive" + }, + "affects": [ + { + "ref": "7f2ee811-6b35-4c24-83ec-605d7939005c" + } + ] + } + ] +} \ No newline at end of file