diff --git a/src/main/java/org/dependencytrack/model/Tag.java b/src/main/java/org/dependencytrack/model/Tag.java index bf71b20d3..785ced42b 100644 --- a/src/main/java/org/dependencytrack/model/Tag.java +++ b/src/main/java/org/dependencytrack/model/Tag.java @@ -50,6 +50,13 @@ public class Tag implements Serializable { private static final long serialVersionUID = -7798359808664731988L; + public Tag() { + } + + public Tag(final String name) { + this.name = name; + } + @PrimaryKey @Persistent(valueStrategy = IdGeneratorStrategy.NATIVE) @JsonIgnore diff --git a/src/main/java/org/dependencytrack/resources/v1/BomResource.java b/src/main/java/org/dependencytrack/resources/v1/BomResource.java index fb37f1a06..ecba61859 100644 --- a/src/main/java/org/dependencytrack/resources/v1/BomResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/BomResource.java @@ -37,6 +37,7 @@ import org.dependencytrack.event.BomUploadEvent; import org.dependencytrack.model.Component; import org.dependencytrack.model.Project; +import org.dependencytrack.model.Tag; import org.dependencytrack.model.WorkflowState; import org.dependencytrack.model.WorkflowStatus; import org.dependencytrack.model.validation.ValidUuid; @@ -73,11 +74,13 @@ import java.nio.file.Files; import java.nio.file.StandardOpenOption; import java.security.Principal; +import java.util.Arrays; import java.util.Base64; import java.util.List; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; +import static java.util.function.Predicate.not; import static org.dependencytrack.model.ConfigPropertyConstants.BOM_VALIDATION_ENABLED; /** @@ -287,7 +290,7 @@ public Response uploadBom(@ApiParam(required = true) BomSubmitRequest request) { } } - project = qm.createProject(StringUtils.trimToNull(request.getProjectName()), null, StringUtils.trimToNull(request.getProjectVersion()), null, parent, null, true, true); + project = qm.createProject(StringUtils.trimToNull(request.getProjectName()), null, StringUtils.trimToNull(request.getProjectVersion()), request.getProjectTags(), parent, null, true, true); Principal principal = getPrincipal(); qm.updateNewProjectACL(project, principal); } else { @@ -330,14 +333,17 @@ public Response uploadBom(@ApiParam(required = true) BomSubmitRequest request) { @ApiResponse(code = 404, message = "The project could not be found") }) @PermissionRequired(Permissions.Constants.BOM_UPLOAD) - 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, - @ApiParam(type = "string") @FormDataParam("bom") final List artifactParts) { + public Response uploadBom( + @FormDataParam("project") String projectUuid, + @DefaultValue("false") @FormDataParam("autoCreate") boolean autoCreate, + @FormDataParam("projectName") String projectName, + @FormDataParam("projectVersion") String projectVersion, + @FormDataParam("projectTags") String projectTags, + @FormDataParam("parentName") String parentName, + @FormDataParam("parentVersion") String parentVersion, + @FormDataParam("parentUUID") String parentUUID, + @ApiParam(type = "string") @FormDataParam("bom") final List artifactParts + ) { if (projectUuid != null) { // behavior in v3.0.0 try (QueryManager qm = new QueryManager()) { final Project project = qm.getObjectByUuid(Project.class, projectUuid); @@ -367,7 +373,10 @@ public Response uploadBom(@FormDataParam("project") String projectUuid, 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); + final List tags = (projectTags != null && !projectTags.isBlank()) + ? Arrays.stream(projectTags.split(",")).map(String::trim).filter(not(String::isEmpty)).map(Tag::new).toList() + : null; + project = qm.createProject(trimmedProjectName, null, trimmedProjectVersion, tags, 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 17830597f..803b74d0b 100644 --- a/src/main/java/org/dependencytrack/resources/v1/vo/BomSubmitRequest.java +++ b/src/main/java/org/dependencytrack/resources/v1/vo/BomSubmitRequest.java @@ -24,10 +24,12 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import io.swagger.annotations.ApiModelProperty; +import org.dependencytrack.model.Tag; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; import javax.validation.constraints.Pattern; +import java.util.List; /** * Defines a custom request object used when uploading bill-of-material (bom) documents. @@ -51,6 +53,8 @@ public final class BomSubmitRequest { @Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS, message = "The project version may only contain printable characters") private final String projectVersion; + private final List projectTags; + @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; @@ -71,15 +75,17 @@ public final class BomSubmitRequest { public BomSubmitRequest(String project, String projectName, String projectVersion, + List projectTags, boolean autoCreate, String bom) { - this(project, projectName, projectVersion, autoCreate, null, null, null, bom); + this(project, projectName, projectVersion, projectTags, autoCreate, null, null, null, bom); } @JsonCreator public BomSubmitRequest(@JsonProperty(value = "project") String project, @JsonProperty(value = "projectName") String projectName, @JsonProperty(value = "projectVersion") String projectVersion, + @JsonProperty(value = "projectTags") List projectTags, @JsonProperty(value = "autoCreate") boolean autoCreate, @JsonProperty(value = "parentUUID") String parentUUID, @JsonProperty(value = "parentName") String parentName, @@ -88,6 +94,7 @@ public BomSubmitRequest(@JsonProperty(value = "project") String project, this.project = project; this.projectName = projectName; this.projectVersion = projectVersion; + this.projectTags = projectTags; this.autoCreate = autoCreate; this.parentUUID = parentUUID; this.parentName = parentName; @@ -110,6 +117,11 @@ public String getProjectVersion() { return projectVersion; } + @ApiModelProperty(example = "tag1, tag2") + public List getProjectTags() { + return projectTags; + } + @ApiModelProperty(example = "5341f53c-611b-4388-9d9c-731026dc5eec") public String getParentUUID() { return parentUUID; diff --git a/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java index a4a179195..c6b81aebb 100644 --- a/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java @@ -44,12 +44,16 @@ import org.dependencytrack.model.ProjectMetadata; import org.dependencytrack.model.ProjectProperty; import org.dependencytrack.model.Severity; +import org.dependencytrack.model.Tag; import org.dependencytrack.model.Vulnerability; import org.dependencytrack.model.WorkflowState; import org.dependencytrack.model.WorkflowStep; import org.dependencytrack.parser.cyclonedx.CycloneDxValidator; import org.dependencytrack.resources.v1.exception.JsonMappingExceptionMapper; import org.dependencytrack.resources.v1.vo.BomSubmitRequest; +import org.glassfish.jersey.client.ClientConfig; +import org.glassfish.jersey.client.HttpUrlConnectorProvider; +import org.glassfish.jersey.media.multipart.FormDataMultiPart; import org.glassfish.jersey.media.multipart.MultiPartFeature; import org.glassfish.jersey.server.ResourceConfig; import org.junit.Assert; @@ -59,11 +63,13 @@ import org.junit.runner.RunWith; import javax.json.JsonObject; +import javax.ws.rs.client.ClientBuilder; import javax.ws.rs.client.Entity; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import java.io.File; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.FileSystems; import java.nio.file.FileVisitResult; import java.nio.file.Files; @@ -77,9 +83,12 @@ import java.util.Date; import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json; +import static org.apache.commons.io.IOUtils.resourceToString; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatNoException; import static org.dependencytrack.model.ConfigPropertyConstants.BOM_VALIDATION_ENABLED; @@ -770,7 +779,7 @@ public void uploadBomTest() throws Exception { Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, true, false); File file = new File(IOUtils.resourceToURL("/unit/bom-1.xml").toURI()); String bomString = Base64.getEncoder().encodeToString(FileUtils.readFileToByteArray(file)); - BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, false, bomString); + BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, null, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -827,7 +836,7 @@ public void uploadNonCycloneDxBomTest() { SPDXVersion: SPDX-2.2 DataLicense: CC0-1.0 """.getBytes()); - BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, false, bomString); + BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, null, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -856,7 +865,7 @@ public void uploadInvalidCycloneDxBomTest() { ] } """.getBytes()); - BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, false, bomString); + BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, null, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -883,7 +892,7 @@ public void uploadInvalidFormatBomTest() throws Exception { Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, true, false); File file = new File(IOUtils.resourceToURL("/unit/bom-invalid.json").toURI()); String bomString = Base64.getEncoder().encodeToString(FileUtils.readFileToByteArray(file)); - BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, false, bomString); + BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, null, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -902,7 +911,7 @@ public void uploadBomInvalidProjectTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD); File file = new File(IOUtils.resourceToURL("/unit/bom-1.xml").toURI()); String bomString = Base64.getEncoder().encodeToString(FileUtils.readFileToByteArray(file)); - BomSubmitRequest request = new BomSubmitRequest(UUID.randomUUID().toString(), null, null, false, bomString); + BomSubmitRequest request = new BomSubmitRequest(UUID.randomUUID().toString(), null, null, null, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -917,7 +926,7 @@ public void uploadBomAutoCreateTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); File file = new File(IOUtils.resourceToURL("/unit/bom-1.xml").toURI()); String bomString = Base64.getEncoder().encodeToString(FileUtils.readFileToByteArray(file)); - BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", true, bomString); + BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -934,7 +943,7 @@ public void uploadBomAutoCreateTest() throws Exception { public void uploadBomUnauthorizedTest() throws Exception { File file = new File(IOUtils.resourceToURL("/unit/bom-1.xml").toURI()); String bomString = Base64.getEncoder().encodeToString(FileUtils.readFileToByteArray(file)); - BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", true, bomString); + BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -949,7 +958,7 @@ public void uploadBomAutoCreateTestWithParentTest() throws Exception { File file = new File(IOUtils.resourceToURL("/unit/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); + BomSubmitRequest request = new BomSubmitRequest(null, "Acme Parent", "1.0", null, true, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -961,7 +970,7 @@ public void uploadBomAutoCreateTestWithParentTest() throws Exception { 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); + request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, parentUUID, null, null, bomString); response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -977,7 +986,7 @@ public void uploadBomAutoCreateTestWithParentTest() throws Exception { // Upload second child, search parent by name+ver - request = new BomSubmitRequest(null, "Acme Example", "2.0", true, null, "Acme Parent", "1.0", bomString); + request = new BomSubmitRequest(null, "Acme Example", "2.0", null, true, null, "Acme Parent", "1.0", bomString); response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -992,7 +1001,7 @@ public void uploadBomAutoCreateTestWithParentTest() throws Exception { 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); + request = new BomSubmitRequest(null, "Acme Example", "3.0", null, true, parentUUID, "Non-existent parent", "1.0", bomString); response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1012,7 +1021,7 @@ public void uploadBomInvalidParentTest() throws Exception { initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); File file = new File(IOUtils.resourceToURL("/unit/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); + BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", null, true, UUID.randomUUID().toString(), null, null, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1020,7 +1029,7 @@ public void uploadBomInvalidParentTest() throws Exception { 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); + request = new BomSubmitRequest(null, "Acme Example", "2.0", null, true, null, "Non-existent parent", null, bomString); response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1055,7 +1064,7 @@ public void uploadBomSchemaValidationTest(final Path filePath) throws Exception Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, true, false); File file = filePath.toFile(); String bomString = Base64.getEncoder().encodeToString(FileUtils.readFileToByteArray(file)); - BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, false, bomString); + BomSubmitRequest request = new BomSubmitRequest(project.getUuid().toString(), null, null, null, false, bomString); Response response = jersey.target(V1_BOM).request() .header(X_API_KEY, apiKey) .put(Entity.entity(request, MediaType.APPLICATION_JSON)); @@ -1250,4 +1259,62 @@ public void uploadBomTooLargeViaPutTest() { """); } + @Test + public void uploadBomAutoCreateWithTagsMultipartTest() throws Exception { + initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); + final var multiPart = new FormDataMultiPart() + .field("bom", resourceToString("/unit/bom-1.xml", StandardCharsets.UTF_8), MediaType.APPLICATION_XML_TYPE) + .field("projectName", "Acme Example") + .field("projectVersion", "1.0") + .field("projectTags", "tag1,tag2") + .field("autoCreate", "true"); + + // NB: The GrizzlyConnectorProvider doesn't work with MultiPart requests. + // https://github.com/eclipse-ee4j/jersey/issues/5094 + final var client = ClientBuilder.newClient(new ClientConfig() + .register(MultiPartFeature.class) + .connectorProvider(new HttpUrlConnectorProvider())); + + final Response response = client.target(jersey.target(V1_BOM).getUri()).request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(multiPart, multiPart.getMediaType())); + assertThat(response.getStatus()).isEqualTo(200); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + { + "token": "${json-unit.any-string}" + } + """); + + final Project project = qm.getProject("Acme Example", "1.0"); + assertThat(project).isNotNull(); + assertThat(project.getTags()) + .extracting(Tag::getName) + .containsExactlyInAnyOrder("tag1", "tag2"); + } + + @Test + public void uploadBomAutoCreateWithTagsTest() throws Exception { + initializeWithPermissions(Permissions.BOM_UPLOAD, Permissions.PROJECT_CREATION_UPLOAD); + File file = new File(IOUtils.resourceToURL("/unit/bom-1.xml").toURI()); + String bomString = Base64.getEncoder().encodeToString(FileUtils.readFileToByteArray(file)); + List tags = Stream.of("tag1", "tag2").map(name -> { + Tag tag = new Tag(); + tag.setName(name); + return tag; + }).collect(Collectors.toList()); + BomSubmitRequest request = new BomSubmitRequest(null, "Acme Example", "1.0", tags, true, bomString); + Response response = jersey.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); + Assert.assertNotNull(json.getString("token")); + Assert.assertTrue(UuidUtil.isValidUUID(json.getString("token"))); + Project project = qm.getProject("Acme Example", "1.0"); + Assert.assertNotNull(project); + assertThat(project.getTags()) + .extracting(Tag::getName) + .containsExactlyInAnyOrder("tag1", "tag2"); + } }