From c4b68d88f89fdb257080b3559a124f895e62f8b9 Mon Sep 17 00:00:00 2001 From: nscuro Date: Fri, 27 Sep 2024 17:21:29 +0200 Subject: [PATCH] Fix `/api/v1/vulnerability/source/{source}/vuln/{vuln}` returning all vulnerable components Fixes https://github.com/DependencyTrack/hyades/issues/1539 Signed-off-by: nscuro --- .../persistence/jdbi/VulnerabilityDao.java | 104 ++++++++++++++++ .../mapping/VulnerableSoftwareRowMapper.java | 54 +++++++++ .../resources/v1/VulnerabilityResource.java | 71 ++++++----- .../v1/VulnerabilityResourceTest.java | 111 ++++++++++++++---- 4 files changed, 287 insertions(+), 53 deletions(-) create mode 100644 src/main/java/org/dependencytrack/persistence/jdbi/mapping/VulnerableSoftwareRowMapper.java diff --git a/src/main/java/org/dependencytrack/persistence/jdbi/VulnerabilityDao.java b/src/main/java/org/dependencytrack/persistence/jdbi/VulnerabilityDao.java index 88c34da37..fe07eb321 100644 --- a/src/main/java/org/dependencytrack/persistence/jdbi/VulnerabilityDao.java +++ b/src/main/java/org/dependencytrack/persistence/jdbi/VulnerabilityDao.java @@ -20,7 +20,9 @@ import org.dependencytrack.model.Component; import org.dependencytrack.model.Vulnerability; +import org.dependencytrack.model.VulnerableSoftware; import org.dependencytrack.persistence.jdbi.mapping.VulnerabilityRowMapper; +import org.dependencytrack.persistence.jdbi.mapping.VulnerableSoftwareRowMapper; import org.jdbi.v3.sqlobject.config.RegisterConstructorMapper; import org.jdbi.v3.sqlobject.config.RegisterFieldMapper; import org.jdbi.v3.sqlobject.config.RegisterRowMapper; @@ -37,6 +39,108 @@ */ public interface VulnerabilityDao { + @SqlQuery(/* language=InjectedFreeMarker */ """ + <#-- @ftlvariable name="uuid" type="Boolean" --> + SELECT "VULNERABILITY"."ID" AS "ID" + , "VULNID" + , "SOURCE" + , "FRIENDLYVULNID" + , "TITLE" + , "SUBTITLE" + , "DESCRIPTION" + , "DETAIL" + , "RECOMMENDATION" + , "REFERENCES" + , "CREDITS" + , "CREATED" + , "PUBLISHED" + , "UPDATED" + , STRING_TO_ARRAY("CWES", ',') AS "CWES" + , "CVSSV2BASESCORE" + , "CVSSV2IMPACTSCORE" + , "CVSSV2EXPLOITSCORE" + , "CVSSV2VECTOR" + , "CVSSV3BASESCORE" + , "CVSSV3IMPACTSCORE" + , "CVSSV3EXPLOITSCORE" + , "CVSSV3VECTOR" + , "OWASPRRLIKELIHOODSCORE" + , "OWASPRRTECHNICALIMPACTSCORE" + , "OWASPRRBUSINESSIMPACTSCORE" + , "OWASPRRVECTOR" + , "SEVERITY" + , "VULNERABLEVERSIONS" + , "PATCHEDVERSIONS" + , "UUID" + , JSONB_VULN_ALIASES("SOURCE", "VULNID") AS "vulnAliasesJson" + FROM "VULNERABILITY" + LEFT JOIN "EPSS" + ON "VULNID" = "EPSS"."CVE" + WHERE 1=1 + <#if uuid> + AND "UUID" = :uuid + <#else> + AND "VULNID" = :vulnId + AND "SOURCE" = :source + + """) + @DefineNamedBindings + @RegisterRowMapper(VulnerabilityRowMapper.class) + Vulnerability getByUuidOrVulnIdAndSource(@Bind UUID uuid, @Bind String vulnId, @Bind String source); + + default Vulnerability getByUuid(final UUID uuid) { + return getByUuidOrVulnIdAndSource(uuid, null, null); + } + + default Vulnerability getByVulnIdAndSource(final String vulnId, final String source) { + return getByUuidOrVulnIdAndSource(null, vulnId, source); + } + + @SqlQuery(""" + SELECT "VS"."ID" + , "VS"."PURL" + , "VS"."PURL_TYPE" + , "VS"."PURL_NAMESPACE" + , "VS"."PURL_NAME" + , "VS"."PURL_VERSION" + , "VS"."PURL_QUALIFIERS" + , "VS"."PURL_SUBPATH" + , "VS"."CPE22" + , "VS"."CPE23" + , "VS"."PART" + , "VS"."VENDOR" + , "VS"."PRODUCT" + , "VS"."VERSION" + , "VS"."UPDATE" + , "VS"."EDITION" + , "VS"."LANGUAGE" + , "VS"."SWEDITION" + , "VS"."TARGETSW" + , "VS"."TARGETHW" + , "VS"."OTHER" + , "VS"."VERSIONENDEXCLUDING" + , "VS"."VERSIONENDINCLUDING" + , "VS"."VERSIONSTARTEXCLUDING" + , "VS"."VERSIONSTARTINCLUDING" + , "VS"."VULNERABLE" + , "VS"."UUID" + , (SELECT JSONB_AGG(JSONB_BUILD_OBJECT( + 'id', "ID" + , 'firstSeen', EXTRACT(EPOCH FROM "FIRST_SEEN") * 1000 + , 'lastSeen', EXTRACT(EPOCH FROM "LAST_SEEN") * 1000 + , 'source', "SOURCE" + , 'uuid', "UUID")) + FROM "AFFECTEDVERSIONATTRIBUTION" + WHERE "VULNERABILITY" = "VSV"."VULNERABILITY_ID" + AND "VULNERABLE_SOFTWARE" = "VSV"."VULNERABLESOFTWARE_ID") AS "attributionsJson" + FROM "VULNERABLESOFTWARE_VULNERABILITIES" AS "VSV" + INNER JOIN "VULNERABLESOFTWARE" AS "VS" + ON "VS"."ID" = "VSV"."VULNERABLESOFTWARE_ID" + WHERE "VSV"."VULNERABILITY_ID" = :id + """) + @RegisterRowMapper(VulnerableSoftwareRowMapper.class) + List getVulnerableSoftwareByVulnId(@Bind long id); + @SqlQuery(/* language=InjectedFreeMarker */ """ <#-- @ftlvariable name="activeFilter" type="Boolean" --> <#-- @ftlvariable name="apiOrderByClause" type="String" --> diff --git a/src/main/java/org/dependencytrack/persistence/jdbi/mapping/VulnerableSoftwareRowMapper.java b/src/main/java/org/dependencytrack/persistence/jdbi/mapping/VulnerableSoftwareRowMapper.java new file mode 100644 index 000000000..72c314f4d --- /dev/null +++ b/src/main/java/org/dependencytrack/persistence/jdbi/mapping/VulnerableSoftwareRowMapper.java @@ -0,0 +1,54 @@ +/* + * 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.persistence.jdbi.mapping; + +import com.fasterxml.jackson.core.type.TypeReference; +import org.dependencytrack.model.AffectedVersionAttribution; +import org.dependencytrack.model.VulnerableSoftware; +import org.jdbi.v3.core.mapper.RowMapper; +import org.jdbi.v3.core.mapper.reflect.BeanMapper; +import org.jdbi.v3.core.statement.StatementContext; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; + +import static org.dependencytrack.persistence.jdbi.mapping.RowMapperUtil.deserializeJson; +import static org.dependencytrack.persistence.jdbi.mapping.RowMapperUtil.maybeSet; + +/** + * @since 5.6.0 + */ +public class VulnerableSoftwareRowMapper implements RowMapper { + + private static final TypeReference> ATTRIBUTIONS_TYPE_REF = new TypeReference<>() { + }; + + private final RowMapper vsBeanMapper = BeanMapper.of(VulnerableSoftware.class); + + @Override + public VulnerableSoftware map(final ResultSet rs, final StatementContext ctx) throws SQLException { + final VulnerableSoftware vs = this.vsBeanMapper.map(rs, ctx); + maybeSet(rs, "attributionsJson", + (ignored, columnName) -> deserializeJson(rs, columnName, ATTRIBUTIONS_TYPE_REF), + vs::setAffectedVersionAttributions); + return vs; + } + +} diff --git a/src/main/java/org/dependencytrack/resources/v1/VulnerabilityResource.java b/src/main/java/org/dependencytrack/resources/v1/VulnerabilityResource.java index 88db177d0..d53af1bb2 100644 --- a/src/main/java/org/dependencytrack/resources/v1/VulnerabilityResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/VulnerabilityResource.java @@ -32,6 +32,7 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirements; import jakarta.validation.Validator; +import jakarta.ws.rs.ClientErrorException; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; @@ -44,7 +45,6 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import org.dependencytrack.auth.Permissions; -import org.dependencytrack.model.AffectedVersionAttribution; import org.dependencytrack.model.AnalyzerIdentity; import org.dependencytrack.model.Component; import org.dependencytrack.model.Cwe; @@ -69,6 +69,7 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; +import java.util.UUID; import static org.dependencytrack.persistence.jdbi.JdbiFactory.withJdbiHandle; @@ -181,16 +182,28 @@ public Response getVulnerabilitiesByProject(@Parameter(description = "The UUID o @ApiResponse(responseCode = "404", description = "The vulnerability could not be found") }) @PermissionRequired({Permissions.Constants.VULNERABILITY_MANAGEMENT, Permissions.Constants.VULNERABILITY_MANAGEMENT_READ}) - public Response getVulnerabilityByUuid(@Parameter(description = "The UUID of the vulnerability", schema = @Schema(type = "string", format = "uuid"), required = true) - @PathParam("uuid") @ValidUuid String uuid) { - try (QueryManager qm = new QueryManager()) { - final Vulnerability vulnerability = qm.getObjectByUuid(Vulnerability.class, uuid); - if (vulnerability != null) { - return Response.ok(vulnerability).build(); - } else { - return Response.status(Response.Status.NOT_FOUND).entity("The vulnerability could not be found.").build(); + public Response getVulnerabilityByUuid( + @Parameter(description = "The UUID of the vulnerability", schema = @Schema(type = "string", format = "uuid"), required = true) + @PathParam("uuid") @ValidUuid final String uuid) { + return withJdbiHandle(getAlpineRequest(), handle -> { + final var dao = handle.attach(VulnerabilityDao.class); + + final Vulnerability vuln = dao.getByUuid(UUID.fromString(uuid)); + if (vuln == null) { + throw new ClientErrorException(Response + .status(Response.Status.NOT_FOUND) + .entity("The vulnerability could not be found.") + .build()); } - } + + final List vsList = dao.getVulnerableSoftwareByVulnId(vuln.getId()); + final List affectedComponents = vsList.stream().map(AffectedComponent::new).toList(); + if (!affectedComponents.isEmpty()) { + vuln.setAffectedComponents(affectedComponents); + } + + return Response.status(Response.Status.OK).entity(vuln).build(); + }); } @GET @@ -210,24 +223,28 @@ public Response getVulnerabilityByUuid(@Parameter(description = "The UUID of the @ApiResponse(responseCode = "404", description = "The vulnerability could not be found") }) @PermissionRequired(Permissions.Constants.VIEW_PORTFOLIO) - public Response getVulnerabilityByVulnId(@PathParam("source") String source, - @PathParam("vuln") String vuln) { - try (QueryManager qm = new QueryManager()) { - final Vulnerability vulnerability = qm.getVulnerabilityByVulnId(source, vuln); - if (vulnerability != null) { - final List affectedComponents = new ArrayList<>(); - for (final VulnerableSoftware vs : vulnerability.getVulnerableSoftware()) { - AffectedComponent affectedComponent = new AffectedComponent(vs); - final List attributions = qm.getAffectedVersionAttributions(vulnerability, vs); - affectedComponent.setAffectedVersionAttributions(attributions); - affectedComponents.add(affectedComponent); - } - vulnerability.setAffectedComponents(affectedComponents); - return Response.ok(vulnerability).build(); - } else { - return Response.status(Response.Status.NOT_FOUND).entity("The vulnerability could not be found.").build(); + public Response getVulnerabilityByVulnId( + @PathParam("source") final String source, + @PathParam("vuln") final String vulnId) { + return withJdbiHandle(getAlpineRequest(), handle -> { + final var dao = handle.attach(VulnerabilityDao.class); + + final Vulnerability vuln = dao.getByVulnIdAndSource(vulnId, source); + if (vuln == null) { + throw new ClientErrorException(Response + .status(Response.Status.NOT_FOUND) + .entity("The vulnerability could not be found.") + .build()); } - } + + final List vsList = dao.getVulnerableSoftwareByVulnId(vuln.getId()); + final List affectedComponents = vsList.stream().map(AffectedComponent::new).toList(); + if (!affectedComponents.isEmpty()) { + vuln.setAffectedComponents(affectedComponents); + } + + return Response.status(Response.Status.OK).entity(vuln).build(); + }); } @GET diff --git a/src/test/java/org/dependencytrack/resources/v1/VulnerabilityResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/VulnerabilityResourceTest.java index 6d19151ff..c8616506c 100644 --- a/src/test/java/org/dependencytrack/resources/v1/VulnerabilityResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/VulnerabilityResourceTest.java @@ -27,6 +27,7 @@ import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import net.javacrumbs.jsonunit.core.Option; import org.dependencytrack.JerseyTestRule; import org.dependencytrack.ResourceTest; import org.dependencytrack.model.AffectedVersionAttribution; @@ -50,6 +51,7 @@ import java.util.UUID; import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.CoreMatchers.equalTo; public class VulnerabilityResourceTest extends ResourceTest { @@ -383,22 +385,53 @@ public void getVulnerabilitiesByProjectInvalidTest() throws Exception { } @Test - public void getVulnerabilityByUuidTest() throws Exception { - SampleData sampleData = new SampleData(); - Response response = jersey.target(V1_VULNERABILITY + "/" + sampleData.v1.getUuid().toString()).request() + public void getVulnerabilityByUuidTest() { + final var sampleData = new SampleData(); + + final Response response = jersey.target(V1_VULNERABILITY + "/" + sampleData.v1.getUuid()).request() .header(X_API_KEY, apiKey) - .get(Response.class); - Assert.assertEquals(200, response.getStatus(), 0); - Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); - JsonObject json = parseJsonObject(response); - Assert.assertNotNull(json); - Assert.assertEquals("INT-1", json.getString("vulnId")); + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isNull(); + assertThatJson(getPlainTextBody(response)) + .withOptions(Option.IGNORING_ARRAY_ORDER) + .isEqualTo(/* language=JSON */ """ + { + "vulnId": "INT-1", + "source": "INTERNAL", + "description": "Description 1", + "cwes": [ + { + "cweId":123, + "name": "Write-what-where Condition" + } + ], + "severity": "CRITICAL", + "uuid": "${json-unit.any-string}", + "affectedComponents": [ + { + "uuid": "${json-unit.any-string}", + "affectedVersionAttributions": [ + { + "firstSeen": "${json-unit.any-number}", + "lastSeen": "${json-unit.any-number}", + "source": "INTERNAL", + "uuid": "${json-unit.any-string}" + } + ] + } + ], + "affectedProjectCount": 0, + "affectedActiveProjectCount": 0, + "affectedInactiveProjectCount": 0 + } + """); } @Test - public void getVulnerabilityByUuidInvalidTest() throws Exception { + public void getVulnerabilityByUuidInvalidTest() { new SampleData(); - Response response = jersey.target(V1_VULNERABILITY + "/" + UUID.randomUUID().toString()).request() + Response response = jersey.target(V1_VULNERABILITY + "/" + UUID.randomUUID()).request() .header(X_API_KEY, apiKey) .get(Response.class); Assert.assertEquals(404, response.getStatus(), 0); @@ -408,25 +441,51 @@ public void getVulnerabilityByUuidInvalidTest() throws Exception { } @Test - public void getVulnerabilityByVulnIdTest() throws Exception { - SampleData sampleData = new SampleData(); - Response response = jersey.target(V1_VULNERABILITY + "/source/" + sampleData.v1.getSource() + "/vuln/" + sampleData.v1.getVulnId()).request() + public void getVulnerabilityByVulnIdTest() { + final var sampleData = new SampleData(); + + final Response response = jersey.target(V1_VULNERABILITY + "/source/" + sampleData.v1.getSource() + "/vuln/" + sampleData.v1.getVulnId()).request() .header(X_API_KEY, apiKey) - .get(Response.class); - Assert.assertEquals(200, response.getStatus(), 0); - Assert.assertNull(response.getHeaderString(TOTAL_COUNT_HEADER)); - JsonObject json = parseJsonObject(response); - Assert.assertNotNull(json); - Assert.assertEquals("INT-1", json.getString("vulnId")); - JsonArray affectedComponents = json.getJsonArray("affectedComponents"); - Assert.assertNotNull(affectedComponents); - JsonArray affectedVersionAttributions = affectedComponents.getJsonObject(0).getJsonArray("affectedVersionAttributions"); - Assert.assertNotNull(affectedVersionAttributions); - Assert.assertEquals(affectedVersionAttributions.getJsonObject(0).getString("source"), "INTERNAL"); + .get(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isNull(); + assertThatJson(getPlainTextBody(response)) + .withOptions(Option.IGNORING_ARRAY_ORDER) + .isEqualTo(/* language=JSON */ """ + { + "vulnId": "INT-1", + "source": "INTERNAL", + "description": "Description 1", + "cwes": [ + { + "cweId":123, + "name": "Write-what-where Condition" + } + ], + "severity": "CRITICAL", + "uuid": "${json-unit.any-string}", + "affectedComponents": [ + { + "uuid": "${json-unit.any-string}", + "affectedVersionAttributions": [ + { + "firstSeen": "${json-unit.any-number}", + "lastSeen": "${json-unit.any-number}", + "source": "INTERNAL", + "uuid": "${json-unit.any-string}" + } + ] + } + ], + "affectedProjectCount": 0, + "affectedActiveProjectCount": 0, + "affectedInactiveProjectCount": 0 + } + """); } @Test - public void getVulnerabilityByVulnIdInvalidTest() throws Exception { + public void getVulnerabilityByVulnIdInvalidTest() { new SampleData(); Response response = jersey.target(V1_VULNERABILITY + "/source/INTERNAL/vuln/blah").request() .header(X_API_KEY, apiKey)