diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/VulnerabilityDao.java b/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/VulnerabilityDao.java index 717d7dd2b3..4072409030 100644 --- a/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/VulnerabilityDao.java +++ b/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/VulnerabilityDao.java @@ -159,6 +159,7 @@ default Vulnerability getByVulnIdAndSource(final String vulnId, final String sou @SqlQuery(/* language=InjectedFreeMarker */ """ <#-- @ftlvariable name="activeFilter" type="Boolean" --> + <#-- @ftlvariable name="searchText" type="Boolean" --> <#-- @ftlvariable name="apiOrderByClause" type="String" --> <#-- @ftlvariable name="apiOffsetLimitClause" type="String" --> <#-- @ftlvariable name="apiProjectAclCondition" type="String" --> @@ -200,6 +201,11 @@ WHERE EXISTS( SELECT 1 FROM "CTE_AFFECTED_COMPONENTS" WHERE "PROJECT_ID" = "PROJECT"."ID") + <#if searchText> + AND ( + LOWER("PROJECT"."NAME") LIKE ('%' || LOWER(:searchText) || '%') + ) + <#if apiOrderByClause??> ${apiOrderByClause} <#else> @@ -217,7 +223,8 @@ WHERE EXISTS( List getAffectedProjects( @Bind String source, @Bind String vulnId, - @Bind Boolean activeFilter); + @Bind Boolean activeFilter, + @Bind @Nullable String searchText); record AffectedProjectListRow( UUID uuid, diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v1/VulnerabilityResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v1/VulnerabilityResource.java index bab915dac0..6ac260824b 100644 --- a/apiserver/src/main/java/org/dependencytrack/resources/v1/VulnerabilityResource.java +++ b/apiserver/src/main/java/org/dependencytrack/resources/v1/VulnerabilityResource.java @@ -19,10 +19,12 @@ package org.dependencytrack.resources.v1; import alpine.persistence.PaginatedResult; +import alpine.resources.AlpineRequest; import alpine.server.auth.PermissionRequired; import alpine.server.filters.ResourceAccessRequired; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.headers.Header; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; @@ -260,7 +262,22 @@ public Response getVulnerabilityByVulnId( @Produces(MediaType.APPLICATION_JSON) @Operation( summary = "Returns a list of all projects affected by a specific vulnerability", - description = "

Requires permission VIEW_PORTFOLIO

" + description = """ +

Requires permission VIEW_PORTFOLIO

\ +

Optional query parameters searchText or filter narrow the list; \ + both are provided by the Alpine request filter and match the same value.

""", + parameters = { + @Parameter( + name = "searchText", + in = ParameterIn.QUERY, + description = "Optional case-insensitive substring match on project name." + ), + @Parameter( + name = "filter", + in = ParameterIn.QUERY, + description = "Optional filter (Alpine); same effect as searchText for this endpoint." + ) + } ) @PaginatedApi @ApiResponses(value = { @@ -278,8 +295,16 @@ public Response getAffectedProject(@PathParam("source") String source, @PathParam("vuln") String vuln, @Parameter(description = "Optionally excludes inactive projects from being returned", required = false) @QueryParam("excludeInactive") boolean excludeInactive) { - final List affectedProjectRows = withJdbiHandle(getAlpineRequest(), handle -> - handle.attach(VulnerabilityDao.class).getAffectedProjects(source, vuln, excludeInactive ? true : null)); + final AlpineRequest alpineRequest = getAlpineRequest(); + final String affectedProjectsFilter; + if (alpineRequest == null) { + affectedProjectsFilter = null; + } else { + final String filter = alpineRequest.getFilter(); + affectedProjectsFilter = (filter == null || filter.isBlank()) ? null : filter; + } + final List affectedProjectRows = withJdbiHandle(alpineRequest, handle -> + handle.attach(VulnerabilityDao.class).getAffectedProjects(source, vuln, excludeInactive ? true : null, affectedProjectsFilter)); final long totalCount = affectedProjectRows.isEmpty() ? 0 : affectedProjectRows.getFirst().totalCount(); final List affectedProjects = affectedProjectRows.stream() diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v1/VulnerabilityResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v1/VulnerabilityResourceTest.java index 6dced018eb..07a121fc2f 100644 --- a/apiserver/src/test/java/org/dependencytrack/resources/v1/VulnerabilityResourceTest.java +++ b/apiserver/src/test/java/org/dependencytrack/resources/v1/VulnerabilityResourceTest.java @@ -627,6 +627,48 @@ public void getAffectedProjectTest() { """); } + @Test + public void getAffectedProjectWithSearchTextTest() { + final var sampleData = new SampleData(); + final Response response = jersey.target(V1_VULNERABILITY + "/source/" + sampleData.v1.getSource() + "/vuln/" + sampleData.v1.getVulnId() + "/projects") + .queryParam("searchText", "Project 1") + .request() + .header(X_API_KEY, apiKey) + .get(Response.class); + Assertions.assertEquals(200, response.getStatus(), 0); + Assertions.assertEquals(String.valueOf(1), response.getHeaderString(TOTAL_COUNT_HEADER)); + assertThatJson(getPlainTextBody(response)) + .withMatcher("projectUuid", equalTo(sampleData.p1.getUuid().toString())) + .withMatcher("componentUuid", equalTo(sampleData.c1.getUuid().toString())) + .isEqualTo(/* language=JSON */ """ + [ + { + "active": true, + "affectedComponentUuids": [ + "${json-unit.matches:componentUuid}" + ], + "dependencyGraphAvailable": false, + "name": "Project 1", + "uuid": "${json-unit.matches:projectUuid}", + "version": null + } + ] + """); + } + + @Test + public void getAffectedProjectWithSearchTextNoMatchTest() { + final var sampleData = new SampleData(); + final Response response = jersey.target(V1_VULNERABILITY + "/source/" + sampleData.v1.getSource() + "/vuln/" + sampleData.v1.getVulnId() + "/projects") + .queryParam("searchText", "does-not-exist") + .request() + .header(X_API_KEY, apiKey) + .get(Response.class); + Assertions.assertEquals(200, response.getStatus(), 0); + Assertions.assertEquals("0", response.getHeaderString(TOTAL_COUNT_HEADER)); + Assertions.assertEquals("[]", getPlainTextBody(response)); + } + @Test public void getAffectedProjectWithDeletedFindingTest() { var project = qm.createProject("Project 1", null, null, null, null, null, null, false);