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