diff --git a/api/src/main/openapi/components/schemas/list-components-response-item.yaml b/api/src/main/openapi/components/schemas/list-components-response-item.yaml index 6acb3fa5e5..ba1da80bdf 100644 --- a/api/src/main/openapi/components/schemas/list-components-response-item.yaml +++ b/api/src/main/openapi/components/schemas/list-components-response-item.yaml @@ -22,6 +22,9 @@ properties: version: type: string maxLength: 255 + latest_version: + type: string + maxLength: 255 group: type: string maxLength: 255 @@ -30,12 +33,19 @@ properties: maxLength: 255 hashes: $ref: "./hashes.yaml" + integrity_check_status: + type: string + maxLength: 255 cpe: type: string maxLength: 255 purl: type: string maxLength: 1024 + published: + $ref: "./timestamp.yaml" + last_fetched: + $ref: "./timestamp.yaml" swid_tag_id: type: string maxLength: 255 @@ -44,6 +54,9 @@ properties: copyright: type: string maxLength: 255 + integrity_repo_url: + type: string + maxLength: 1024 license: type: string maxLength: 255 @@ -62,6 +75,30 @@ properties: last_inherited_risk_score: type: number format: double + vulnerabilities: + type: integer + format: int32 + minimum: 0 + critical: + type: integer + format: int32 + minimum: 0 + high: + type: integer + format: int32 + minimum: 0 + medium: + type: integer + format: int32 + minimum: 0 + low: + type: integer + format: int32 + minimum: 0 + unassigned: + type: integer + format: int32 + minimum: 0 uuid: type: string format: uuid \ No newline at end of file diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/ComponentDao.java b/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/ComponentDao.java index ed886bf503..f35737e217 100644 --- a/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/ComponentDao.java +++ b/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/ComponentDao.java @@ -20,6 +20,7 @@ import org.dependencytrack.model.Component; import org.dependencytrack.model.ComponentOccurrence; +import org.dependencytrack.model.IntegrityMatchStatus; import org.dependencytrack.model.License; import org.dependencytrack.persistence.pagination.Page; import org.jdbi.v3.core.mapper.RowMapper; @@ -35,6 +36,8 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; import java.util.List; import java.util.UUID; @@ -88,21 +91,23 @@ AND LOWER("LOCATION") LIKE ('%' || LOWER(${apiFilterParameter}) || '%') """) Long getComponentId(@Bind UUID componentUuid); - default Page listProjectComponents(final long projectId, final Boolean onlyOutdated, + default Page listProjectComponents(final long projectId, final Boolean onlyOutdated, final Boolean onlyDirect, final int limit, final String pageToken) { final var decodedPageToken = decodePageToken(getHandle(), pageToken, ListComponentPageToken.class); - final List rows = listProjectComponents(projectId, limit + 1, onlyOutdated, onlyDirect, + final List rows = listProjectComponents(projectId, limit + 1, onlyOutdated, onlyDirect, decodedPageToken != null ? decodedPageToken.lastName() : null, decodedPageToken != null ? decodedPageToken.lastVersion() : null, decodedPageToken != null ? decodedPageToken.lastId() : null); - final List resultRows = rows.size() > 1 + final List resultRows = rows.size() > 1 ? rows.subList(0, Math.min(rows.size(), limit)) : rows; final ListComponentPageToken nextPageToken = rows.size() > limit - ? new ListComponentPageToken(resultRows.getLast().getName(), resultRows.getLast().getVersion(), resultRows.getLast().getId()) + ? new ListComponentPageToken(resultRows.getLast().component.getName(), + resultRows.getLast().component.getVersion(), + resultRows.getLast().component.getId()) : null; return new Page<>(resultRows, encodePageToken(getHandle(), nextPageToken)); @@ -149,9 +154,38 @@ record ListComponentPageToken(String lastName, String lastVersion, Long lastId) "L"."ISOSIAPPROVED", "L"."UUID" AS "licenseUuid", "L"."NAME" AS "licenseName", + "INTEGRITY_META_COMPONENT"."PUBLISHED_AT" AS "published", + "INTEGRITY_META_COMPONENT"."LAST_FETCH" AS "lastFetched", + "INTEGRITY_META_COMPONENT"."REPOSITORY_URL" AS "integrityRepoUrl", + "INTEGRITY_ANALYSIS"."INTEGRITY_CHECK_STATUS" AS "integrityCheckStatus", + "DEPENDENCYMETRICS"."VULNERABILITIES" AS "vulnCount", + "DEPENDENCYMETRICS"."CRITICAL" AS "critical", + "DEPENDENCYMETRICS"."HIGH" AS "high", + "DEPENDENCYMETRICS"."MEDIUM" AS "medium", + "DEPENDENCYMETRICS"."LOW" AS "low", + "DEPENDENCYMETRICS"."UNASSIGNED_SEVERITY" AS "unassigned", + "R"."LATEST_VERSION" AS "latestVersion", (SELECT COUNT(*) FROM "COMPONENT_OCCURRENCE" WHERE "COMPONENT_ID" = "C"."ID") AS "occurrenceCount" FROM "COMPONENT" "C" INNER JOIN "PROJECT" ON "C"."PROJECT_ID" = "PROJECT"."ID" + LEFT JOIN "INTEGRITY_META_COMPONENT" ON "C"."PURL" = "INTEGRITY_META_COMPONENT"."PURL" + LEFT JOIN "INTEGRITY_ANALYSIS" ON "C"."ID" = "INTEGRITY_ANALYSIS"."COMPONENT_ID" + LEFT JOIN ( + SELECT DISTINCT ON ("COMPONENT_ID") + "COMPONENT_ID", + "VULNERABILITIES", + "CRITICAL", + "HIGH", + "MEDIUM", + "LOW", + "UNASSIGNED_SEVERITY" + FROM "DEPENDENCYMETRICS" + ORDER BY "COMPONENT_ID", "LAST_OCCURRENCE" DESC + ) "DEPENDENCYMETRICS" ON "C"."ID" = "DEPENDENCYMETRICS"."COMPONENT_ID" + LEFT JOIN "REPOSITORY_META_COMPONENT" "R" + ON "R"."NAME" = "C"."NAME" + AND ("R"."NAMESPACE" = "C"."GROUP" OR "R"."NAMESPACE" IS NULL OR "C"."GROUP" IS NULL) + AND "C"."PURL" LIKE (('pkg:' || LOWER("R"."REPOSITORY_TYPE")) || '/%') ESCAPE E'\\\\' LEFT OUTER JOIN "LICENSE" "L" ON "C"."LICENSE_ID" = "L"."ID" WHERE ${apiProjectAclCondition} AND "C"."PROJECT_ID" = :projectId @@ -177,7 +211,7 @@ AND NOT (NOT EXISTS ( @DefineNamedBindings @DefineApiProjectAclCondition(projectIdColumn = "\"PROJECT_ID\"") @RegisterRowMapper(ComponentListRowMapper.class) - List listProjectComponents( + List listProjectComponents( @Bind long projectId, @Bind int limit, @Bind Boolean onlyOutdated, @@ -187,12 +221,28 @@ List listProjectComponents( @Bind Long lastId ); - class ComponentListRowMapper implements RowMapper { + record ComponentRow( + Component component, + String latestVersion, + Instant published, + Instant lastFetched, + IntegrityMatchStatus integrityCheckStatus, + String integrityRepoUrl, + int vulnerabilities, + int critical, + int medium, + int high, + int low, + int unassigned + ) { + } + + class ComponentListRowMapper implements RowMapper { private final RowMapper componentRowMapper = BeanMapper.of(Component.class); @Override - public Component map(final ResultSet rs, final StatementContext ctx) throws SQLException { + public ComponentRow map(final ResultSet rs, final StatementContext ctx) throws SQLException { final Component component = componentRowMapper.map(rs, ctx); maybeSet(rs, "componentPurl", ResultSet::getString, component::setPurl); if (rs.getString("licenseUuid") != null) { @@ -206,7 +256,29 @@ public Component map(final ResultSet rs, final StatementContext ctx) throws SQLE component.setResolvedLicense(license); } maybeSet(rs, "occurrenceCount", ResultSet::getLong, component::setOccurrenceCount); - return component; + + final Timestamp publishedTs = rs.getTimestamp("published"); + final Instant published = (publishedTs != null ? publishedTs.toInstant() : null); + final Timestamp lastFetchedTs = rs.getTimestamp("lastFetched"); + final Instant lastFetched = (lastFetchedTs != null ? lastFetchedTs.toInstant() : null); + final String integrityCheckStatus = rs.getString("integrityCheckStatus"); + final IntegrityMatchStatus integrityStatus = + (integrityCheckStatus != null ? IntegrityMatchStatus.valueOf(integrityCheckStatus) : null); + + return new ComponentRow( + component, + rs.getString("latestVersion"), + published, + lastFetched, + integrityStatus, + rs.getString("integrityRepoUrl"), + rs.getInt("vulnCount"), + rs.getInt("critical"), + rs.getInt("high"), + rs.getInt("medium"), + rs.getInt("low"), + rs.getInt("unassigned") + ); } } } diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/ProjectsResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/ProjectsResource.java index 489ded9730..4b55f4555a 100644 --- a/apiserver/src/main/java/org/dependencytrack/resources/v2/ProjectsResource.java +++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/ProjectsResource.java @@ -28,7 +28,6 @@ import org.dependencytrack.api.v2.model.ListComponentsResponse; import org.dependencytrack.api.v2.model.ListComponentsResponseItem; import org.dependencytrack.auth.Permissions; -import org.dependencytrack.model.Component; import org.dependencytrack.persistence.jdbi.ComponentDao; import org.dependencytrack.persistence.jdbi.ProjectDao; import org.dependencytrack.persistence.pagination.Page; @@ -56,30 +55,41 @@ public Response listProjectComponents(UUID uuid, Boolean onlyOutdated, Boolean o throw new NotFoundException(); } requireProjectAccess(handle, UUID.fromString(String.valueOf(uuid))); - final Page componentsPage = handle.attach(ComponentDao.class) + final Page componentsPage = handle.attach(ComponentDao.class) .listProjectComponents(projectId, onlyOutdated, onlyDirect, limit, pageToken); final var response = ListComponentsResponse.builder() .components(componentsPage.items().stream() .map( componentRow -> ListComponentsResponseItem.builder() - .name(componentRow.getName()) - .hashes(mapHashes(componentRow)) - .classifier(componentRow.getClassifier() != null ? componentRow.getClassifier().name() : null) - .copyright(componentRow.getCopyright()) - .cpe(componentRow.getCpe()) - .group(componentRow.getGroup()) - .internal(componentRow.isInternal()) - .lastInheritedRiskScore(componentRow.getLastInheritedRiskScore()) - .license(componentRow.getLicense()) - .licenseExpression(componentRow.getLicenseExpression()) - .licenseUrl(componentRow.getLicenseUrl()) - .resolvedLicense(mapLicense(componentRow.getResolvedLicense())) - .occurrenceCount(componentRow.getOccurrenceCount()) - .purl(componentRow.getPurl().toString()) - .swidTagId(componentRow.getSwidTagId()) - .uuid(componentRow.getUuid()) - .version(componentRow.getVersion()) + .name(componentRow.component().getName()) + .hashes(mapHashes(componentRow.component())) + .classifier(componentRow.component().getClassifier() != null ? componentRow.component().getClassifier().name() : null) + .copyright(componentRow.component().getCopyright()) + .cpe(componentRow.component().getCpe()) + .group(componentRow.component().getGroup()) + .internal(componentRow.component().isInternal()) + .lastInheritedRiskScore(componentRow.component().getLastInheritedRiskScore()) + .license(componentRow.component().getLicense()) + .licenseExpression(componentRow.component().getLicenseExpression()) + .licenseUrl(componentRow.component().getLicenseUrl()) + .resolvedLicense(mapLicense(componentRow.component().getResolvedLicense())) + .occurrenceCount(componentRow.component().getOccurrenceCount()) + .purl(componentRow.component().getPurl().toString()) + .swidTagId(componentRow.component().getSwidTagId()) + .uuid(componentRow.component().getUuid()) + .version(componentRow.component().getVersion()) + .published(componentRow.published() != null ? componentRow.published().getEpochSecond() : null) + .lastFetched(componentRow.lastFetched() != null ? componentRow.lastFetched().getEpochSecond() : null) + .latestVersion(componentRow.latestVersion()) + .integrityCheckStatus(componentRow.integrityCheckStatus() != null ? componentRow.integrityCheckStatus().name() : null) + .integrityRepoUrl(componentRow.integrityRepoUrl()) + .vulnerabilities(componentRow.vulnerabilities()) + .critical(componentRow.critical()) + .high(componentRow.high()) + .medium(componentRow.medium()) + .low(componentRow.low()) + .unassigned(componentRow.unassigned()) .build()) .toList()) .pagination(createPaginationMetadata(uriInfo, componentsPage)) diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v2/ProjectsResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v2/ProjectsResourceTest.java index 5c3396a98d..c7e9539b73 100644 --- a/apiserver/src/test/java/org/dependencytrack/resources/v2/ProjectsResourceTest.java +++ b/apiserver/src/test/java/org/dependencytrack/resources/v2/ProjectsResourceTest.java @@ -24,17 +24,31 @@ import org.dependencytrack.ResourceTest; import org.dependencytrack.auth.Permissions; import org.dependencytrack.model.Component; +import org.dependencytrack.model.DependencyMetrics; +import org.dependencytrack.model.FetchStatus; +import org.dependencytrack.model.IntegrityAnalysis; +import org.dependencytrack.model.IntegrityMatchStatus; +import org.dependencytrack.model.IntegrityMetaComponent; import org.dependencytrack.model.License; import org.dependencytrack.model.Project; +import org.dependencytrack.model.RepositoryMetaComponent; +import org.dependencytrack.model.RepositoryType; +import org.dependencytrack.persistence.jdbi.MetricsTestDao; import org.junit.Assert; import org.junit.ClassRule; import org.junit.Test; import java.net.URI; +import java.time.Instant; +import java.time.LocalDate; +import java.util.Date; import java.util.UUID; import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; import static org.assertj.core.api.Assertions.assertThat; +import static org.dependencytrack.model.IntegrityMatchStatus.HASH_MATCH_PASSED; +import static org.dependencytrack.model.IntegrityMatchStatus.HASH_MATCH_UNKNOWN; +import static org.dependencytrack.persistence.jdbi.JdbiFactory.useJdbiHandle; public class ProjectsResourceTest extends ResourceTest { @@ -57,17 +71,28 @@ public void listProjectComponents() { assertThatJson(responseJson.toString()).isEqualTo(/* language=JSON */ """ { "components" : [ { - "name" : "component-name", + "name" : "bar", "version" : "3.0", - "group" : "component-group", + "group" : "foo", + "integrity_check_status":"HASH_MATCH_PASSED", + "latest_version": "3.0", "purl" : "pkg:maven/foo/bar@3.0", + "published": "${json-unit.any-number}", + "last_fetched": "${json-unit.any-number}", "internal" : false, "occurrence_count" : 0, - "uuid" : "${json-unit.any-string}" + "uuid" : "${json-unit.any-string}", + "vulnerabilities": 8, + "critical": 5, + "high": 0, + "medium": 0, + "low": 3, + "unassigned": 0 }, { - "name" : "component-name", + "name" : "bar", "version" : "2.0", - "group" : "component-group", + "latest_version": "3.0", + "group" : "foo", "purl" : "pkg:maven/foo/bar@2.0", "internal" : false, "resolved_license" : { @@ -79,7 +104,13 @@ public void listProjectComponents() { "custom_license" : false }, "occurrence_count" : 0, - "uuid" : "${json-unit.any-string}" + "uuid" : "${json-unit.any-string}", + "vulnerabilities": 0, + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + "unassigned": 0 } ], "_pagination": { @@ -105,16 +136,23 @@ public void listProjectComponents() { assertThatJson(getPlainTextBody(response)).isEqualTo(/* language=JSON */ """ { "components" : [ { - "name" : "component-name", + "name" : "bar", "version" : "1.0", - "group" : "component-group", + "latest_version": "3.0", + "group" : "foo", "purl" : "pkg:maven/foo/bar@1.0", "internal" : false, "occurrence_count" : 0, "hashes": { "md5": "hash-md5" }, - "uuid" : "${json-unit.any-string}" + "uuid" : "${json-unit.any-string}", + "vulnerabilities": 0, + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + "unassigned": 0 } ], "_pagination": { @@ -188,8 +226,8 @@ private Project prepareProject() { Component component = new Component(); component.setProject(project); - component.setGroup("component-group"); - component.setName("component-name"); + component.setGroup("foo"); + component.setName("bar"); component.setVersion("1.0"); component.setPurl("pkg:maven/foo/bar@1.0"); component.setMd5("hash-md5"); @@ -197,20 +235,63 @@ private Project prepareProject() { component = new Component(); component.setProject(project); - component.setGroup("component-group"); - component.setName("component-name"); + component.setGroup("foo"); + component.setName("bar"); component.setVersion("2.0"); component.setPurl("pkg:maven/foo/bar@2.0"); component.setResolvedLicense(license); qm.createComponent(component, false); - component = new Component(); - component.setProject(project); - component.setGroup("component-group"); - component.setName("component-name"); - component.setVersion("3.0"); - component.setPurl("pkg:maven/foo/bar@3.0"); - qm.createComponent(component, false); + final var component3 = new Component(); + component3.setProject(project); + component3.setGroup("foo"); + component3.setName("bar"); + component3.setVersion("3.0"); + component3.setPurl("pkg:maven/foo/bar@3.0"); + qm.createComponent(component3, false); + + RepositoryMetaComponent meta = new RepositoryMetaComponent(); + Date lastCheck = new Date(); + meta.setLastCheck(lastCheck); + meta.setNamespace("foo"); + meta.setName("bar"); + meta.setLatestVersion("3.0"); + meta.setRepositoryType(RepositoryType.MAVEN); + qm.persist(meta); + + IntegrityAnalysis integrityAnalysis = new IntegrityAnalysis(); + integrityAnalysis.setComponent(component3); + integrityAnalysis.setIntegrityCheckStatus(IntegrityMatchStatus.HASH_MATCH_PASSED); + Date published = new Date(); + integrityAnalysis.setUpdatedAt(published); + integrityAnalysis.setId(component3.getId()); + integrityAnalysis.setMd5HashMatchStatus(IntegrityMatchStatus.HASH_MATCH_PASSED); + integrityAnalysis.setSha1HashMatchStatus(HASH_MATCH_UNKNOWN); + integrityAnalysis.setSha256HashMatchStatus(HASH_MATCH_UNKNOWN); + integrityAnalysis.setSha512HashMatchStatus(HASH_MATCH_PASSED); + qm.persist(integrityAnalysis); + + IntegrityMetaComponent integrityMetaComponent = new IntegrityMetaComponent(); + integrityMetaComponent.setPurl(component3.getPurl().toString()); + integrityMetaComponent.setPublishedAt(published); + integrityMetaComponent.setLastFetch(published); + integrityMetaComponent.setStatus(FetchStatus.PROCESSED); + qm.createIntegrityMetaComponent(integrityMetaComponent); + + // Create metrics for component. + useJdbiHandle(handle -> { + var dao = handle.attach(MetricsTestDao.class); + dao.createMetricsPartitionsForDate("DEPENDENCYMETRICS", LocalDate.of(2025, 1, 1)); + var metrics = new DependencyMetrics(); + metrics.setProjectId(project.getId()); + metrics.setComponentId(component3.getId()); + metrics.setVulnerabilities(8); + metrics.setCritical(5); + metrics.setLow(3); + metrics.setFirstOccurrence(Date.from(Instant.now())); + metrics.setLastOccurrence(Date.from(Instant.now())); + dao.createDependencyMetrics(metrics); + }); return project; }