diff --git a/src/main/java/org/dependencytrack/persistence/QueryManager.java b/src/main/java/org/dependencytrack/persistence/QueryManager.java index 98b92ff9c..d4375742e 100644 --- a/src/main/java/org/dependencytrack/persistence/QueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/QueryManager.java @@ -1505,6 +1505,14 @@ public void untagNotificationRules(final String tagName, final Collection getTaggedVulnerabilities(final String tagName) { + return getTagQueryManager().getTaggedVulnerabilities(tagName); + } + + public void untagVulnerabilities(final String tagName, final Collection vulnerabilityUuids) { + getTagQueryManager().untagVulnerabilities(tagName, vulnerabilityUuids); + } + /** * Fetch multiple objects from the data store by their ID. * diff --git a/src/main/java/org/dependencytrack/persistence/TagQueryManager.java b/src/main/java/org/dependencytrack/persistence/TagQueryManager.java index 19fed86b8..ffc489b57 100644 --- a/src/main/java/org/dependencytrack/persistence/TagQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/TagQueryManager.java @@ -32,6 +32,7 @@ import org.dependencytrack.model.Policy; import org.dependencytrack.model.Project; import org.dependencytrack.model.Tag; +import org.dependencytrack.model.Vulnerability; import javax.jdo.PersistenceManager; import javax.jdo.Query; @@ -79,6 +80,7 @@ public record TagListRow( long projectCount, long policyCount, long notificationRuleCount, + long vulnerabilityCount, long totalCount ) { } @@ -107,6 +109,9 @@ public List getTags() { , (SELECT COUNT(*) FROM "NOTIFICATIONRULE_TAGS" WHERE "NOTIFICATIONRULE_TAGS"."TAG_ID" = "TAG"."ID") AS "notificationRuleCount" + , (SELECT COUNT(*) + FROM "VULNERABILITIES_TAGS" + WHERE "VULNERABILITIES_TAGS"."TAG_ID" = "TAG"."ID") AS "vulnerabilityCount" , COUNT(*) OVER() AS "totalCount" FROM "TAG" """.formatted(projectAclCondition); @@ -123,7 +128,8 @@ public List getTags() { } else if ("name".equals(orderBy) || "projectCount".equals(orderBy) || "policyCount".equals(orderBy) - || "notificationRuleCount".equals(orderBy)) { + || "notificationRuleCount".equals(orderBy) + || "vulnerabilityCount".equals(orderBy)) { sqlQuery += " ORDER BY \"%s\" %s, \"ID\" ASC".formatted(orderBy, orderDirection == OrderDirection.DESCENDING ? "DESC" : "ASC"); } else { @@ -151,7 +157,8 @@ public record TagDeletionCandidateRow( long projectCount, long accessibleProjectCount, long policyCount, - long notificationRuleCount + long notificationRuleCount, + long vulnerabilityCount ) { } @@ -198,6 +205,11 @@ public void deleteTags(final Collection tagNames) { INNER JOIN "NOTIFICATIONRULE" ON "NOTIFICATIONRULE"."ID" = "NOTIFICATIONRULE_TAGS"."NOTIFICATIONRULE_ID" WHERE "NOTIFICATIONRULE_TAGS"."TAG_ID" = "TAG"."ID") AS "notificationRuleCount" + , (SELECT COUNT(*) + FROM "VULNERABILITIES_TAGS" + INNER JOIN "VULNERABILITY" + ON "VULNERABILITY"."ID" = "VULNERABILITIES_TAGS"."VULNERABILITY_ID" + WHERE "VULNERABILITIES_TAGS"."TAG_ID" = "TAG"."ID") AS "vulnerabilityCount" FROM "TAG" WHERE %s """.formatted(projectAclCondition, String.join(" OR ", tagNameFilters))); @@ -222,10 +234,12 @@ public void deleteTags(final Collection tagNames) { boolean hasPortfolioManagementUpdatePermission = false; boolean hasPolicyManagementUpdatePermission = false; + boolean hasvulnerabilityManagementUpdatePermission = false; boolean hasSystemConfigurationUpdatePermission = false; if (principal == null) { hasPortfolioManagementUpdatePermission = true; hasPolicyManagementUpdatePermission = true; + hasvulnerabilityManagementUpdatePermission = true; hasSystemConfigurationUpdatePermission = true; } else { if (principal instanceof final ApiKey apiKey) { @@ -235,6 +249,8 @@ public void deleteTags(final Collection tagNames) { || hasPermission(apiKey, Permissions.Constants.POLICY_MANAGEMENT_UPDATE); hasSystemConfigurationUpdatePermission = hasPermission(apiKey, Permissions.Constants.SYSTEM_CONFIGURATION) || hasPermission(apiKey, Permissions.Constants.SYSTEM_CONFIGURATION_UPDATE); + hasvulnerabilityManagementUpdatePermission = hasPermission(apiKey, Permissions.Constants.VULNERABILITY_MANAGEMENT) + || hasPermission(apiKey, Permissions.Constants.VULNERABILITY_MANAGEMENT_UPDATE); } else if (principal instanceof final UserPrincipal user) { hasPortfolioManagementUpdatePermission = hasPermission(user, Permissions.Constants.PORTFOLIO_MANAGEMENT, /* includeTeams */ true) || hasPermission(user, Permissions.Constants.PORTFOLIO_MANAGEMENT_UPDATE, /* includeTeams */ true); @@ -242,6 +258,8 @@ public void deleteTags(final Collection tagNames) { || hasPermission(user, Permissions.Constants.POLICY_MANAGEMENT_UPDATE, /* includeTeams */ true); hasSystemConfigurationUpdatePermission = hasPermission(user, Permissions.Constants.SYSTEM_CONFIGURATION, /* includeTeams */ true) || hasPermission(user, Permissions.Constants.SYSTEM_CONFIGURATION_UPDATE, /* includeTeams */ true); + hasvulnerabilityManagementUpdatePermission = hasPermission(user, Permissions.Constants.VULNERABILITY_MANAGEMENT, /* includeTeams */ true) + || hasPermission(user, Permissions.Constants.VULNERABILITY_MANAGEMENT_UPDATE, /* includeTeams */ true); } } @@ -276,6 +294,13 @@ public void deleteTags(final Collection tagNames) { is missing the %s or %s permission.""".formatted(row.notificationRuleCount(), Permissions.SYSTEM_CONFIGURATION, Permissions.SYSTEM_CONFIGURATION_UPDATE)); } + + if (row.vulnerabilityCount() > 0 && !hasvulnerabilityManagementUpdatePermission) { + errorByTagName.put(row.name(), """ + The tag is assigned to %d vulnerabilities, but the authenticated principal \ + is missing the %s or %s permission.""".formatted(row.vulnerabilityCount(), + Permissions.VULNERABILITY_MANAGEMENT, Permissions.VULNERABILITY_MANAGEMENT_UPDATE)); + } } if (!errorByTagName.isEmpty()) { @@ -700,4 +725,68 @@ public void untagNotificationRules(final String tagName, final Collection getTaggedVulnerabilities(final String tagName) { + // language=SQL + var sqlQuery = """ + SELECT "VULNERABILITY"."UUID" AS "uuid" + , "VULNERABILITY"."VULNID" AS "vulnId" + , "VULNERABILITY"."SOURCE" AS "source" + , COUNT(*) OVER() AS "totalCount" + FROM "VULNERABILITY" + INNER JOIN "VULNERABILITIES_TAGS" + ON "VULNERABILITIES_TAGS"."VULNERABILITY_ID" = "VULNERABILITY"."ID" + INNER JOIN "TAG" + ON "TAG"."ID" = "VULNERABILITIES_TAGS"."TAG_ID" + WHERE "TAG"."NAME" = :tag + """; + + final var params = new HashMap(); + params.put("tag", tagName); + + if (filter != null) { + sqlQuery += " AND \"VULNERABILITY\".\"VULNID\" LIKE :vulnIdFilter"; + params.put("vulnIdFilter", "%" + filter + "%"); + } + + if (orderBy == null) { + sqlQuery += " ORDER BY \"vulnId\" ASC"; + } else if ("vulnId".equals(orderBy)) { + sqlQuery += " ORDER BY \"%s\" %s".formatted(orderBy, + orderDirection == OrderDirection.DESCENDING ? "DESC" : "ASC"); + } else { + throw new NotSortableException("TaggedVulnerability", orderBy, "Field does not exist or is not sortable"); + } + + sqlQuery += " " + getOffsetLimitSqlClause(); + + final Query query = pm.newQuery(Query.SQL, sqlQuery); + query.setNamedParameters(params); + return executeAndCloseResultList(query, TaggedVulnerabilityRow.class); + } + + @Override + public void untagVulnerabilities(final String tagName, final Collection vulnerabilityUuids) { + runInTransaction(() -> { + final Tag tag = getTagByName(tagName); + if (tag == null) { + throw new NoSuchElementException("A tag with name %s does not exist".formatted(tagName)); + } + final Query vulnerabilityQuery = pm.newQuery(Vulnerability.class); + vulnerabilityQuery.setFilter(":uuids.contains(uuid)"); + vulnerabilityQuery.setParameters(vulnerabilityUuids); + final List vulnerabilities = executeAndCloseList(vulnerabilityQuery); + + for (final Vulnerability vulnerability : vulnerabilities) { + if (vulnerability.getTags() == null || vulnerability.getTags().isEmpty()) { + continue; + } + vulnerability.getTags().remove(tag); + } + }); + } } diff --git a/src/main/java/org/dependencytrack/resources/v1/TagResource.java b/src/main/java/org/dependencytrack/resources/v1/TagResource.java index 42eb46cc5..a8096346b 100644 --- a/src/main/java/org/dependencytrack/resources/v1/TagResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/TagResource.java @@ -54,6 +54,7 @@ import org.dependencytrack.resources.v1.vo.TaggedNotificationRuleListResponseItem; import org.dependencytrack.resources.v1.vo.TaggedPolicyListResponseItem; import org.dependencytrack.resources.v1.vo.TaggedProjectListResponseItem; +import org.dependencytrack.resources.v1.vo.TaggedVulnerabilityListResponseItem; import java.util.List; import java.util.Set; @@ -93,7 +94,8 @@ public Response getAllTags() { row.name(), row.projectCount(), row.policyCount(), - row.notificationRuleCount() + row.notificationRuleCount(), + row.vulnerabilityCount() )) .toList(); final long totalCount = tagListRows.isEmpty() ? 0 : tagListRows.getFirst().totalCount(); @@ -501,4 +503,77 @@ public Response untagNotificationRules( return Response.noContent().build(); } + + @GET + @Path("/{name}/vulnerability") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Returns a list of all vulnerabilities assigned to the given tag.", + description = "

Requires permission VULNERABILITY_MANAGEMENT or VULNERABILITY_MANAGEMENT_READ

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "A list of all vulnerabilities assigned to the given tag", + headers = @Header(name = TOTAL_COUNT_HEADER, description = "The total number of vulnerabilities", schema = @Schema(format = "integer")), + content = @Content(array = @ArraySchema(schema = @Schema(implementation = TaggedVulnerabilityListResponseItem.class))) + ) + }) + @PaginatedApi + @PermissionRequired({Permissions.Constants.VULNERABILITY_MANAGEMENT, Permissions.Constants.VULNERABILITY_MANAGEMENT_READ}) + public Response getTaggedVulnerabilities( + @Parameter(description = "Name of the tag to get vulnerabilities for.", required = true) + @PathParam("name") final String tagName + ) { + // TODO: Should enforce lowercase for tagName once we are sure that + // users don't have any mixed-case tags in their system anymore. + // Will likely need a migration to cleanup existing tags for this. + + final List taggedVulnerabilityListRows; + try (final var qm = new QueryManager(getAlpineRequest())) { + taggedVulnerabilityListRows = qm.getTaggedVulnerabilities(tagName); + } + + final List tags = taggedVulnerabilityListRows.stream() + .map(row -> new TaggedVulnerabilityListResponseItem(row.uuid(), row.vulnId(), row.source())) + .toList(); + final long totalCount = taggedVulnerabilityListRows.isEmpty() ? 0 : taggedVulnerabilityListRows.getFirst().totalCount(); + return Response.ok(tags).header(TOTAL_COUNT_HEADER, totalCount).build(); + } + + @DELETE + @Path("/{name}/vulnerability") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Untags one or more vulnerabilities.", + description = "

Requires permission VULNERABILITY_MANAGEMENT or VULNERABILITY_MANAGEMENT_UPDATE

" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "204", + description = "Vulnerabilities untagged successfully." + ), + @ApiResponse( + responseCode = "404", + description = "A tag with the provided name does not exist.", + content = @Content(schema = @Schema(implementation = ProblemDetails.class), mediaType = ProblemDetails.MEDIA_TYPE_JSON) + ) + }) + @PermissionRequired({Permissions.Constants.VULNERABILITY_MANAGEMENT, Permissions.Constants.VULNERABILITY_MANAGEMENT_UPDATE}) + public Response untagVulnerabilities( + @Parameter(description = "Name of the tag", required = true) + @PathParam("name") final String tagName, + @Parameter( + description = "UUIDs of vulnerabilities to untag", + required = true, + array = @ArraySchema(schema = @Schema(type = "string", format = "uuid")) + ) + @Size(min = 1, max = 100) final Set<@ValidUuid String> vulnerabilityUuids + ) { + try (final var qm = new QueryManager(getAlpineRequest())) { + qm.untagVulnerabilities(tagName, vulnerabilityUuids); + } + return Response.noContent().build(); + } } diff --git a/src/main/java/org/dependencytrack/resources/v1/vo/TagListResponseItem.java b/src/main/java/org/dependencytrack/resources/v1/vo/TagListResponseItem.java index 505e53935..3931b44fd 100644 --- a/src/main/java/org/dependencytrack/resources/v1/vo/TagListResponseItem.java +++ b/src/main/java/org/dependencytrack/resources/v1/vo/TagListResponseItem.java @@ -27,6 +27,7 @@ public record TagListResponseItem( @Parameter(description = "Name of the tag", required = true) String name, @Parameter(description = "Number of projects assigned to this tag") long projectCount, @Parameter(description = "Number of policies assigned to this tag") long policyCount, - @Parameter(description = "Number of notification rules assigned to this tag") long notificationRuleCount + @Parameter(description = "Number of notification rules assigned to this tag") long notificationRuleCount, + @Parameter(description = "Number of vulnerabilities assigned to this tag") long vulnerabilityCount ) { } \ No newline at end of file diff --git a/src/main/java/org/dependencytrack/resources/v1/vo/TaggedVulnerabilityListResponseItem.java b/src/main/java/org/dependencytrack/resources/v1/vo/TaggedVulnerabilityListResponseItem.java new file mode 100644 index 000000000..b2b0e73ad --- /dev/null +++ b/src/main/java/org/dependencytrack/resources/v1/vo/TaggedVulnerabilityListResponseItem.java @@ -0,0 +1,35 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.resources.v1.vo; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.Parameter; + +import java.util.UUID; + +/** + * @since 5.6.0 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record TaggedVulnerabilityListResponseItem( + @Parameter(description = "UUID of the vulnerability", required = true) UUID uuid, + @Parameter(description = "Vulnerability ID", required = true) String vulnId, + @Parameter(description = "Source of the vulnerability", required = true) String source +) { +} \ No newline at end of file diff --git a/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java index 709a55b85..84b30fb18 100644 --- a/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/TagResourceTest.java @@ -32,6 +32,7 @@ import org.dependencytrack.model.Policy; import org.dependencytrack.model.Project; import org.dependencytrack.model.Tag; +import org.dependencytrack.model.Vulnerability; import org.dependencytrack.notification.NotificationScope; import org.dependencytrack.resources.v1.exception.ConstraintViolationExceptionMapper; import org.dependencytrack.resources.v1.exception.NoSuchElementExceptionMapper; @@ -118,6 +119,12 @@ public void getTagsTest() { qm.bind(notificationRuleA, List.of(tagFoo)); // NB: Not assigning notificationRuleB + final var vulnA = new Vulnerability(); + vulnA.setVulnId("vuln-a"); + vulnA.setSource(Vulnerability.Source.INTERNAL); + qm.persist(vulnA); + qm.bind(vulnA, List.of(tagFoo)); + final Response response = jersey.target(V1_TAG) .request() .header(X_API_KEY, apiKey) @@ -130,13 +137,15 @@ public void getTagsTest() { "name": "bar", "projectCount": 1, "policyCount": 1, - "notificationRuleCount": 0 + "notificationRuleCount": 0, + "vulnerabilityCount": 0 }, { "name": "foo", "projectCount": 2, "policyCount": 0, - "notificationRuleCount": 1 + "notificationRuleCount": 1, + "vulnerabilityCount": 1 } ] """); @@ -163,19 +172,22 @@ public void getTagsWithPaginationTest() { "name": "tag-1", "projectCount": 0, "policyCount": 0, - "notificationRuleCount": 0 + "notificationRuleCount": 0, + "vulnerabilityCount": 0 }, { "name": "tag-2", "projectCount": 0, "policyCount": 0, - "notificationRuleCount": 0 + "notificationRuleCount": 0, + "vulnerabilityCount": 0 }, { "name": "tag-3", "projectCount": 0, "policyCount": 0, - "notificationRuleCount": 0 + "notificationRuleCount": 0, + "vulnerabilityCount": 0 } ] """); @@ -194,13 +206,15 @@ public void getTagsWithPaginationTest() { "name": "tag-4", "projectCount": 0, "policyCount": 0, - "notificationRuleCount": 0 + "notificationRuleCount": 0, + "vulnerabilityCount": 0 }, { "name": "tag-5", "projectCount": 0, "policyCount": 0, - "notificationRuleCount": 0 + "notificationRuleCount": 0, + "vulnerabilityCount": 0 } ] """); @@ -225,7 +239,8 @@ public void getTagsWithFilterTest() { "name": "foo", "projectCount": 0, "policyCount": 0, - "notificationRuleCount": 0 + "notificationRuleCount": 0, + "vulnerabilityCount": 0 } ] """); @@ -262,13 +277,15 @@ public void getTagsSortByProjectCountTest() { "name": "foo", "projectCount": 2, "policyCount": 0, - "notificationRuleCount": 0 + "notificationRuleCount": 0, + "vulnerabilityCount": 0 }, { "name": "bar", "projectCount": 1, "policyCount": 0, - "notificationRuleCount": 0 + "notificationRuleCount": 0, + "vulnerabilityCount": 0 } ] """); @@ -1706,4 +1723,336 @@ public void untagNotificationRulesWhenNotTaggedTest() { qm.getPersistenceManager().evictAll(); assertThat(notificationRule.getTags()).isEmpty(); } + + @Test + public void getTaggedVulnerabilitiesTest() { + initializeWithPermissions(Permissions.VULNERABILITY_MANAGEMENT); + + final var vulnA = new Vulnerability(); + vulnA.setVulnId("vuln-a"); + vulnA.setSource(Vulnerability.Source.INTERNAL); + qm.persist(vulnA); + + final var vulnB = new Vulnerability(); + vulnB.setVulnId("vuln-b"); + vulnB.setSource(Vulnerability.Source.INTERNAL); + qm.persist(vulnB); + + final Tag tagFoo = qm.createTag("foo"); + final Tag tagBar = qm.createTag("bar"); + + qm.bind(vulnA, List.of(tagFoo)); + qm.bind(vulnB, List.of(tagFoo, tagBar)); + + final Response response = jersey.target(V1_TAG + "/foo/vulnerability") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("2"); + assertThatJson(getPlainTextBody(response)) + .withMatcher("vulnUuidA", equalTo(vulnA.getUuid().toString())) + .withMatcher("vulnUuidB", equalTo(vulnB.getUuid().toString())) + .isEqualTo(""" + [ + { + "uuid": "${json-unit.matches:vulnUuidA}", + "vulnId": "vuln-a", + "source": "INTERNAL" + }, + { + "uuid": "${json-unit.matches:vulnUuidB}", + "vulnId": "vuln-b", + "source": "INTERNAL" + } + ] + """); + } + + @Test + public void getTaggedVulnerabilitiesWithPaginationTest() { + initializeWithPermissions(Permissions.VULNERABILITY_MANAGEMENT); + final Tag tag = qm.createTag("foo"); + + for (int i = 0; i < 5; i++) { + final var vuln = new Vulnerability(); + vuln.setVulnId("vuln-" + (i + 1)); + vuln.setSource(Vulnerability.Source.INTERNAL); + qm.persist(vuln); + qm.bind(vuln, List.of(tag)); + } + + Response response = jersey.target(V1_TAG + "/foo/vulnerability") + .queryParam("pageNumber", "1") + .queryParam("pageSize", "3") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("5"); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "uuid": "${json-unit.any-string}", + "vulnId": "vuln-1", + "source": "INTERNAL" + }, + { + "uuid": "${json-unit.any-string}", + "vulnId": "vuln-2", + "source": "INTERNAL" + }, + { + "uuid": "${json-unit.any-string}", + "vulnId": "vuln-3", + "source": "INTERNAL" + } + ] + """); + + response = jersey.target(V1_TAG + "/foo/vulnerability") + .queryParam("pageNumber", "2") + .queryParam("pageSize", "3") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("5"); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "uuid": "${json-unit.any-string}", + "vulnId": "vuln-4", + "source": "INTERNAL" + }, + { + "uuid": "${json-unit.any-string}", + "vulnId": "vuln-5", + "source": "INTERNAL" + } + ] + """); + } + + @Test + public void getTaggedVulnerabilitiesWithTagNotExistsTest() { + initializeWithPermissions(Permissions.VULNERABILITY_MANAGEMENT); + qm.createTag("foo"); + final Response response = jersey.target(V1_TAG + "/foo/vulnerability") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("0"); + assertThat(getPlainTextBody(response)).isEqualTo("[]"); + } + + @Test + public void getTaggedVulnerabilitiesWithNonLowerCaseTagNameTest() { + initializeWithPermissions(Permissions.VULNERABILITY_MANAGEMENT); + final Response response = jersey.target(V1_TAG + "/Foo/vulnerability") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isEqualTo("0"); + assertThat(getPlainTextBody(response)).isEqualTo("[]"); + } + + @Test + public void untagVulnerabilitiesTest() { + initializeWithPermissions(Permissions.VULNERABILITY_MANAGEMENT); + + final var vulnA = new Vulnerability(); + vulnA.setVulnId("vuln-a"); + vulnA.setSource(Vulnerability.Source.INTERNAL); + qm.persist(vulnA); + + final var vulnB = new Vulnerability(); + vulnB.setVulnId("vuln-b"); + vulnB.setSource(Vulnerability.Source.INTERNAL); + qm.persist(vulnB); + + final Tag tagFoo = qm.createTag("foo"); + + qm.bind(vulnA, List.of(tagFoo)); + qm.bind(vulnB, List.of(tagFoo)); + + final Response response = jersey.target(V1_TAG + "/foo/vulnerability") + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(List.of(vulnA.getUuid(), vulnB.getUuid()))); + assertThat(response.getStatus()).isEqualTo(204); + + qm.getPersistenceManager().evictAll(); + assertThat(vulnA.getTags()).isEmpty(); + assertThat(vulnB.getTags().isEmpty()); + } + + @Test + public void untagVulnerabilitiesWithTagNotExistsTest() { + initializeWithPermissions(Permissions.VULNERABILITY_MANAGEMENT); + + final var vulnA = new Vulnerability(); + vulnA.setVulnId("vuln-a"); + vulnA.setSource(Vulnerability.Source.INTERNAL); + qm.persist(vulnA); + + final Response response = jersey.target(V1_TAG + "/foo/vulnerability") + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(List.of(vulnA.getUuid()))); + + assertThat(response.getStatus()).isEqualTo(404); + assertThat(response.getHeaderString("Content-Type")).isEqualTo("application/problem+json"); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + { + "status": 404, + "title": "Resource does not exist", + "detail": "A tag with name foo does not exist" + } + """); + } + + @Test + public void untagVulnerabilitiesWithNoVulnerabilityUuidsTest() { + initializeWithPermissions(Permissions.VULNERABILITY_MANAGEMENT); + + qm.createTag("foo"); + + final Response response = jersey.target(V1_TAG + "/foo/vulnerability") + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(Collections.emptyList())); + + assertThat(response.getStatus()).isEqualTo(400); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "message": "size must be between 1 and 100", + "messageTemplate": "{jakarta.validation.constraints.Size.message}", + "path": "untagVulnerabilities.vulnerabilityUuids", + "invalidValue": "[]" + } + ] + """); + } + + @Test + public void untagVulnerabilitiesWithTooManyVulnerabilityUuidsTest() { + initializeWithPermissions(Permissions.VULNERABILITY_MANAGEMENT); + + qm.createTag("foo"); + + final List vulnUuids = IntStream.range(0, 101) + .mapToObj(ignored -> UUID.randomUUID()) + .map(UUID::toString) + .toList(); + + final Response response = jersey.target(V1_TAG + "/foo/vulnerability") + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(vulnUuids)); + + assertThat(response.getStatus()).isEqualTo(400); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + [ + { + "message": "size must be between 1 and 100", + "messageTemplate": "{jakarta.validation.constraints.Size.message}", + "path": "untagVulnerabilities.vulnerabilityUuids", + "invalidValue": "${json-unit.any-string}" + } + ] + """); + } + + @Test + public void untagVulnerabilitiesWhenNotTaggedTest() { + initializeWithPermissions(Permissions.VULNERABILITY_MANAGEMENT); + + final var vulnA = new Vulnerability(); + vulnA.setVulnId("vuln-a"); + vulnA.setSource(Vulnerability.Source.INTERNAL); + qm.persist(vulnA); + + qm.createTag("foo"); + + final Response response = jersey.target(V1_TAG + "/foo/vulnerability") + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(List.of(vulnA.getUuid()))); + assertThat(response.getStatus()).isEqualTo(204); + + qm.getPersistenceManager().evictAll(); + assertThat(vulnA.getTags()).isEmpty(); + } + + @Test + public void deleteTagsWhenAssignedToVulnerabilityTest() { + initializeWithPermissions(Permissions.VULNERABILITY_MANAGEMENT, Permissions.TAG_MANAGEMENT); + + final Tag unusedTag = qm.createTag("foo"); + final Tag usedTag = qm.createTag("bar"); + + final var vulnA = new Vulnerability(); + vulnA.setVulnId("vuln-a"); + vulnA.setSource(Vulnerability.Source.INTERNAL); + qm.persist(vulnA); + + qm.bind(vulnA, List.of(usedTag)); + + final Response response = jersey.target(V1_TAG) + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(List.of(unusedTag.getName(), usedTag.getName()))); + assertThat(response.getStatus()).isEqualTo(204); + + qm.getPersistenceManager().evictAll(); + assertThat(qm.getTagByName("foo")).isNull(); + assertThat(qm.getTagByName("bar")).isNull(); + } + + @Test + public void deleteTagsWhenAssignedToVulnerabilityWithoutVulnerabilityManagementPermissionTest() { + initializeWithPermissions(Permissions.TAG_MANAGEMENT); + + final Tag unusedTag = qm.createTag("foo"); + final Tag usedTag = qm.createTag("bar"); + + final var vulnA = new Vulnerability(); + vulnA.setVulnId("vuln-a"); + vulnA.setSource(Vulnerability.Source.INTERNAL); + qm.persist(vulnA); + + qm.bind(vulnA, List.of(usedTag)); + + final Response response = jersey.target(V1_TAG) + .request() + .header(X_API_KEY, apiKey) + .property(ClientProperties.SUPPRESS_HTTP_COMPLIANCE_VALIDATION, true) + .method(HttpMethod.DELETE, Entity.json(List.of(unusedTag.getName(), usedTag.getName()))); + assertThat(response.getStatus()).isEqualTo(400); + assertThat(response.getHeaderString("Content-Type")).isEqualTo("application/problem+json"); + assertThatJson(getPlainTextBody(response)).isEqualTo(""" + { + "status": 400, + "title": "Tag operation failed", + "detail": "The tag(s) bar could not be deleted", + "errors": { + "bar": "The tag is assigned to 1 vulnerabilities, but the authenticated principal is missing the VULNERABILITY_MANAGEMENT or VULNERABILITY_MANAGEMENT_UPDATE permission." + } + } + """); + + qm.getPersistenceManager().evictAll(); + assertThat(qm.getTagByName("foo")).isNotNull(); + assertThat(qm.getTagByName("bar")).isNotNull(); + } }