diff --git a/api/src/main/openapi/components/schemas/component-project.yaml b/api/src/main/openapi/components/schemas/component-project.yaml new file mode 100644 index 0000000000..28a88be563 --- /dev/null +++ b/api/src/main/openapi/components/schemas/component-project.yaml @@ -0,0 +1,27 @@ +# 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. +type: object +properties: + name: + type: string + maxLength: 255 + version: + type: string + maxLength: 255 + uuid: + type: string + format: uuid \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/dependency-metrics.yaml b/api/src/main/openapi/components/schemas/dependency-metrics.yaml new file mode 100644 index 0000000000..e22a966ae1 --- /dev/null +++ b/api/src/main/openapi/components/schemas/dependency-metrics.yaml @@ -0,0 +1,96 @@ +# 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. +type: object +properties: + critical: + type: integer + format: int32 + high: + type: integer + format: int32 + medium: + type: integer + format: int32 + low: + type: integer + format: int32 + unassigned: + type: integer + format: int32 + vulnerabilities: + type: integer + format: int32 + suppressed: + type: integer + format: int32 + inherited_risk_score: + type: number + format: double + findings_total: + type: integer + format: int32 + findings_audited: + type: integer + format: int32 + findings_unaudited: + type: integer + format: int32 + policy_violations_fail: + type: integer + format: int32 + policy_violations_warn: + type: integer + format: int32 + policy_violations_info: + type: integer + format: int32 + policy_violations_total: + type: integer + format: int32 + policy_violations_audited: + type: integer + format: int32 + policy_violations_unaudited: + type: integer + format: int32 + policy_violations_security_total: + type: integer + format: int32 + policy_violations_security_audited: + type: integer + format: int32 + policy_violations_security_unaudited: + type: integer + format: int32 + policy_violations_license_total: + type: integer + format: int32 + policy_violations_license_audited: + type: integer + format: int32 + policy_violations_license_unaudited: + type: integer + format: int32 + policy_violations_operational_total: + type: integer + format: int32 + policy_violations_operational_audited: + type: integer + format: int32 + policy_violations_operational_unaudited: + type: integer + format: int32 \ No newline at end of file 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..348c5192bc 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 @@ -55,13 +55,13 @@ properties: maxLength: 255 resolved_license: $ref: "./license.yaml" - occurrence_count: - type: integer - format: int64 - minimum: 0 last_inherited_risk_score: type: number format: double uuid: type: string - format: uuid \ No newline at end of file + format: uuid + project: + $ref: "./component-project.yaml" + metrics: + $ref: "./dependency-metrics.yaml" \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/list-project-components-response-item.yaml b/api/src/main/openapi/components/schemas/list-project-components-response-item.yaml new file mode 100644 index 0000000000..6acb3fa5e5 --- /dev/null +++ b/api/src/main/openapi/components/schemas/list-project-components-response-item.yaml @@ -0,0 +1,67 @@ +# 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. +type: object +properties: + name: + type: string + maxLength: 255 + version: + type: string + maxLength: 255 + group: + type: string + maxLength: 255 + classifier: + type: string + maxLength: 255 + hashes: + $ref: "./hashes.yaml" + cpe: + type: string + maxLength: 255 + purl: + type: string + maxLength: 1024 + swid_tag_id: + type: string + maxLength: 255 + internal: + type: boolean + copyright: + type: string + maxLength: 255 + license: + type: string + maxLength: 255 + license_expression: + type: string + maxLength: 255 + license_url: + type: string + maxLength: 255 + resolved_license: + $ref: "./license.yaml" + occurrence_count: + type: integer + format: int64 + minimum: 0 + last_inherited_risk_score: + type: number + format: double + uuid: + type: string + format: uuid \ No newline at end of file diff --git a/api/src/main/openapi/components/schemas/list-project-components-response.yaml b/api/src/main/openapi/components/schemas/list-project-components-response.yaml new file mode 100644 index 0000000000..285af717fa --- /dev/null +++ b/api/src/main/openapi/components/schemas/list-project-components-response.yaml @@ -0,0 +1,26 @@ +# 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. +type: object +allOf: +- $ref: "./paginated-response.yaml" +properties: + items: + type: array + items: + $ref: "./list-project-components-response-item.yaml" +required: +- items \ No newline at end of file diff --git a/api/src/main/openapi/paths/components.yaml b/api/src/main/openapi/paths/components.yaml index 0fdb354523..e69a4d4eb9 100644 --- a/api/src/main/openapi/paths/components.yaml +++ b/api/src/main/openapi/paths/components.yaml @@ -43,5 +43,114 @@ post: $ref: "../components/responses/generic-forbidden-error.yaml" "409": $ref: "../components/responses/generic-conflict-error.yaml" + default: + $ref: "../components/responses/generic-error.yaml" + +get: + operationId: listComponents + summary: List all components + description: |- + Retrieves a list of all components that have the specified component identity. This resource accepts coordinates (group, name, version) , `purl`, `cpe`, `swidTagId`, `project_uuid`, or `hash`. + + ### Sortable fields + + Sorting is supported for the following fields: + + * `name` + * `version` + * `group` + * `purl` + * `cpe` + * `swid_tag_id` + * `last_inherited_risk_score` + + Requires permission VIEW_PORTFOLIO + tags: + - Components + parameters: + - name: project_uuid + in: query + description: The UUID of the project to retrieve components for + schema: + type: string + format: uuid + - name: group + in: query + description: The group of the component + schema: + type: string + - name: name + in: query + description: The name of the component + schema: + type: string + - name: version + in: query + description: The version of the component + schema: + type: string + - name: purl + in: query + description: The PURL of the component + schema: + type: string + - name: cpe + in: query + description: The CPE of the component + schema: + type: string + - name: swid_tag_id + in: query + description: The SWID Tag ID of the component + schema: + type: string + - name: hash_type + in: query + description: The MD5, SHA1, SHA_256, SHA_384, SHA_512, SHA3_256, SHA3_384, SHA3_512, BLAKE2B_256, BLAKE2B_384, BLAKE2B_512, or BLAKE3 hash type of the component + schema: + type: string + enum: + - MD5 + - SHA1 + - SHA_256 + - SHA_384 + - SHA_512 + - SHA3_256 + - SHA3_384 + - SHA3_512 + - BLAKE2B_256 + - BLAKE2B_384 + - BLAKE2B_512 + - BLAKE3 + - name: hash + in: query + description: The hash value of the component + schema: + type: string + - $ref: "../components/parameters/pagination-limit.yaml" + - $ref: "../components/parameters/page-token.yaml" + - $ref: "../components/parameters/sort-direction.yaml" + - $ref: "../components/parameters/sort-by.yaml" + responses: + "200": + description: A list of all components for a given identity + content: + application/json: + schema: + $ref: "../components/schemas/list-components-response.yaml" + "400": + description: Bad Request + content: + application/problem+json: + schema: + anyOf: + - $ref: "../components/schemas/json-schema-validation-problem-details.yaml" + - $ref: "../components/schemas/problem-details.yaml" + "401": + $ref: "../components/responses/generic-unauthorized-error.yaml" + "403": + $ref: "../components/responses/generic-forbidden-error.yaml" + "404": + $ref: "../components/responses/generic-not-found-error.yaml" default: $ref: "../components/responses/generic-error.yaml" \ No newline at end of file diff --git a/api/src/main/openapi/paths/projects_uuid_components.yaml b/api/src/main/openapi/paths/projects_uuid_components.yaml index 6cac56151e..a2754871ae 100644 --- a/api/src/main/openapi/paths/projects_uuid_components.yaml +++ b/api/src/main/openapi/paths/projects_uuid_components.yaml @@ -46,7 +46,7 @@ get: content: application/json: schema: - $ref: "../components/schemas/list-components-response.yaml" + $ref: "../components/schemas/list-project-components-response.yaml" "401": $ref: "../components/responses/generic-unauthorized-error.yaml" "403": 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 b95aa4ba81..cb3ee49363 100644 --- a/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/ComponentDao.java +++ b/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/ComponentDao.java @@ -19,11 +19,15 @@ package org.dependencytrack.persistence.jdbi; import org.dependencytrack.common.pagination.Page; +import org.dependencytrack.common.pagination.Page.TotalCount; import org.dependencytrack.common.pagination.PageToken; import org.dependencytrack.common.pagination.PageTokenEncoder; +import org.dependencytrack.common.pagination.SortDirection; import org.dependencytrack.model.Component; import org.dependencytrack.model.ComponentOccurrence; +import org.dependencytrack.model.DependencyMetrics; import org.dependencytrack.model.License; +import org.dependencytrack.model.Project; import org.jdbi.v3.core.mapper.RowMapper; import org.jdbi.v3.core.mapper.reflect.BeanMapper; import org.jdbi.v3.core.statement.StatementContext; @@ -31,18 +35,26 @@ import org.jdbi.v3.sqlobject.config.RegisterBeanMapper; import org.jdbi.v3.sqlobject.config.RegisterRowMapper; import org.jdbi.v3.sqlobject.customizer.Bind; +import org.jdbi.v3.sqlobject.customizer.BindMap; +import org.jdbi.v3.sqlobject.customizer.Define; import org.jdbi.v3.sqlobject.customizer.DefineNamedBindings; import org.jdbi.v3.sqlobject.statement.SqlQuery; import org.jdbi.v3.sqlobject.statement.SqlUpdate; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import static org.dependencytrack.persistence.jdbi.mapping.RowMapperUtil.hasColumn; import static org.dependencytrack.persistence.jdbi.mapping.RowMapperUtil.maybeSet; -public interface ComponentDao extends SqlObject { +public interface ComponentDao extends SqlObject, PaginationSupport { @SqlUpdate(""" DELETE @@ -108,13 +120,13 @@ default Page listProjectComponents( : rows; final ListComponentPageToken nextPageToken = rows.size() > limit - ? new ListComponentPageToken(resultRows.getLast().getName(), resultRows.getLast().getVersion(), resultRows.getLast().getId()) + ? new ListComponentPageToken(resultRows.getLast().getName(), resultRows.getLast().getVersion(), resultRows.getLast().getId(), null) : null; return new Page<>(resultRows, pageTokenEncoder.encode(nextPageToken)); } - record ListComponentPageToken(String lastName, String lastVersion, Long lastId) implements PageToken { + record ListComponentPageToken(String lastName, String lastVersion, Long lastId, TotalCount totalCount) implements PageToken { } @SqlQuery(/* language=InjectedFreeMarker */ """ @@ -130,10 +142,10 @@ record ListComponentPageToken(String lastName, String lastVersion, Long lastId) "C"."CLASSIFIER", "C"."COPYRIGHT", "C"."CPE", - "C"."PURL" AS "componentPurl", + "C"."PURL", "C"."GROUP", "C"."INTERNAL", - "C"."LAST_RISKSCORE" AS "lastInheritedRiskScore", + "C"."LAST_RISKSCORE", "C"."LICENSE" AS "componentLicenseName", "C"."LICENSE_EXPRESSION" AS "licenseExpression", "C"."LICENSE_URL" AS "licenseUrl", @@ -193,6 +205,270 @@ List listProjectComponents( @Bind Long lastId ); + default Page listComponents( + final Long projectId, + final Boolean includeMetrics, + final String componentPurl, + final String componentCpe, + final String componentSwidTagId, + final String componentGroup, + final String componentName, + final String componentVersion, + final HashType componentHashType, + final String componentHash, + final int limit, + final String pageToken, + final String sortBy, + final SortDirection sortDirection) { + final PageTokenEncoder pageTokenEncoder = + getHandle().getConfig(PaginationConfig.class).getPageTokenEncoder(); + final var decodedPageToken = pageTokenEncoder.decode(pageToken, ListComponentPageToken.class); + + TotalCount totalCount; + final var whereConditions = new ArrayList(); + final var queryParams = new HashMap(); + whereConditions.add("TRUE"); + if (projectId != null) { + whereConditions.add("\"C\".\"PROJECT_ID\" = :projectId"); + queryParams.put("projectId", projectId); + } + if (componentGroup != null) { + whereConditions.add("LOWER(\"C\".\"GROUP\") LIKE ('%' || LOWER(:componentGroup) || '%')"); + queryParams.put("componentGroup", componentGroup); + } + if (componentName != null) { + whereConditions.add("LOWER(\"C\".\"NAME\") LIKE ('%' || LOWER(:componentName) || '%')"); + queryParams.put("componentName", componentName); + } + if (componentVersion != null) { + whereConditions.add("LOWER(\"C\".\"VERSION\") LIKE ('%' || LOWER(:componentVersion) || '%')"); + queryParams.put("componentVersion", componentVersion); + } + if (componentPurl != null) { + whereConditions.add("LOWER(\"C\".\"PURL\") LIKE LOWER(:componentPurl) || '%'"); + queryParams.put("componentPurl", componentPurl); + } + if (componentCpe != null) { + whereConditions.add("LOWER(\"C\".\"CPE\") LIKE ('%' || LOWER(:componentCpe) || '%')"); + queryParams.put("componentCpe", componentCpe); + } + if (componentSwidTagId != null) { + whereConditions.add("LOWER(\"C\".\"SWIDTAGID\") LIKE ('%' || LOWER(:componentSwidTagId) || '%')"); + queryParams.put("componentSwidTagId", componentSwidTagId); + } + if (componentHashType != null && componentHash != null) { + final String hashColumn = switch (componentHashType) { + case MD5 -> "\"C\".\"MD5\""; + case SHA1 -> "\"C\".\"SHA1\""; + case SHA_256 -> "\"C\".\"SHA_256\""; + case SHA_384 -> "\"C\".\"SHA_384\""; + case SHA_512 -> "\"C\".\"SHA_512\""; + case SHA3_256 -> "\"C\".\"SHA3_256\""; + case SHA3_384 -> "\"C\".\"SHA3_384\""; + case SHA3_512 -> "\"C\".\"SHA3_512\""; + case BLAKE2B_256 -> "\"C\".\"BLAKE2B_256\""; + case BLAKE2B_384 -> "\"C\".\"BLAKE2B_384\""; + case BLAKE2B_512 -> "\"C\".\"BLAKE2B_512\""; + case BLAKE3 -> "\"C\".\"BLAKE3\""; + }; + whereConditions.add("%s = :componentHash".formatted(hashColumn)); + queryParams.put("componentHash", componentHash); + } + + if (decodedPageToken != null) { + totalCount = decodedPageToken.totalCount(); + } else { + totalCount = getBoundedTotalCountWithProjectAcl(""" + FROM "COMPONENT" "C" + WHERE %s + """.formatted(String.join(" AND ", whereConditions)), + queryParams, + 10000, + "\"C\".\"PROJECT_ID\""); + } + + final String cursorPrimary = decodedPageToken != null ? decodedPageToken.lastName() : null; + final Long cursorId = decodedPageToken != null ? decodedPageToken.lastId() : null; + final boolean hasCursor = decodedPageToken != null; + final String sortDirectionSql = sortDirection != null ? sortDirection.name() : "ASC"; + + var sortByColumn = switch (sortBy) { + case "name" -> SortBy.NAME; + case "version" -> SortBy.VERSION; + case "group" -> SortBy.GROUP; + case "purl" -> SortBy.PURL; + case "cpe" -> SortBy.CPE; + case "swid_tag_id" -> SortBy.SWIDTAGID; + case "last_inherited_risk_score" -> SortBy.LAST_RISKSCORE; + case null, default -> null; + }; + + final List rows = listComponents(whereConditions, queryParams, limit + 1, + cursorPrimary, + cursorId, + sortByColumn, sortDirectionSql, hasCursor); + + final List resultRows = rows.size() > 1 + ? rows.subList(0, Math.min(rows.size(), limit)) + : rows; + + final ListComponentPageToken nextPageToken; + if (rows.size() > limit) { + final Component lastRow = resultRows.getLast(); + final Object lastPrimary = switch (sortByColumn) { + case SortBy.NAME -> lastRow.getName(); + case SortBy.VERSION -> lastRow.getVersion(); + case SortBy.GROUP -> lastRow.getGroup(); + case SortBy.PURL -> lastRow.getPurl(); + case SortBy.CPE -> lastRow.getCpe(); + case SortBy.SWIDTAGID -> lastRow.getSwidTagId(); + case SortBy.LAST_RISKSCORE -> lastRow.getLastInheritedRiskScore(); + case null -> lastRow.getName(); + }; + final String lastSecondary = sortByColumn == null ? lastRow.getVersion() : null; + nextPageToken = new ListComponentPageToken( + lastPrimary != null ? lastPrimary.toString() : null, + lastSecondary, + lastRow.getId(), + totalCount); + } else { + nextPageToken = null; + } + + if (includeMetrics) { + final Map componentById = resultRows.stream() + .collect(Collectors.toMap(Component::getId, Function.identity())); + final List metricsList = getHandle().attach(MetricsDao.class) + .getMostRecentDependencyMetrics(componentById.keySet()); + for (final DependencyMetrics metrics : metricsList) { + final var component = componentById.get(metrics.getComponentId()); + if (component != null) { + component.setMetrics(metrics); + } + } + } + + return new Page<>(resultRows, pageTokenEncoder.encode(nextPageToken), totalCount); + } + + @SqlQuery(/* language=InjectedFreeMarker */ """ + <#-- @ftlvariable name="apiProjectAclCondition" type="String" --> + <#-- @ftlvariable name="sortBy" type="org.dependencytrack.persistence.jdbi.SortBy" --> + <#-- @ftlvariable name="whereConditions" type="java.util.Collection" --> + SELECT "C"."ID", + "C"."NAME", + "C"."BLAKE2B_256", + "C"."BLAKE2B_384", + "C"."BLAKE2B_512", + "C"."BLAKE3", + "C"."CLASSIFIER", + "C"."COPYRIGHT", + "C"."CPE", + "C"."PURL", + "C"."GROUP", + "C"."INTERNAL", + "C"."LAST_RISKSCORE", + "C"."LICENSE" AS "componentLicenseName", + "C"."LICENSE_EXPRESSION" AS "licenseExpression", + "C"."LICENSE_URL" AS "licenseUrl", + "C"."TEXT", + "C"."MD5", + "C"."SHA1", + "C"."SHA_256" AS "sha256", + "C"."SHA_384" AS "sha384", + "C"."SHA_512" AS "sha512", + "C"."SHA3_256", + "C"."SHA3_384", + "C"."SHA3_512", + "C"."SWIDTAGID", + "C"."UUID", + "C"."VERSION", + "L"."LICENSEID", + "L"."UUID" AS "licenseUuid", + "L"."NAME" AS "licenseName", + "PROJECT"."NAME" AS "projectName", + "PROJECT"."UUID" AS "projectUuid", + "PROJECT"."VERSION" AS "projectVersion" + FROM "COMPONENT" "C" + INNER JOIN "PROJECT" ON "C"."PROJECT_ID" = "PROJECT"."ID" + LEFT OUTER JOIN "LICENSE" "L" ON "C"."LICENSE_ID" = "L"."ID" + WHERE ${apiProjectAclCondition} + AND ${whereConditions?join(" AND ")} + <#if hasCursor && sortByColumn?has_content> + AND ( + <#if sortDirection == "DESC"> + ("C"."${sortByColumn}" < + <#if sortByColumn == "LAST_RISKSCORE" > CAST(:lastPrimaryValue AS DOUBLE PRECISION) + <#else> :lastPrimaryValue + + OR ("C"."${sortByColumn}" = + <#if sortByColumn == "LAST_RISKSCORE" > CAST(:lastPrimaryValue AS DOUBLE PRECISION) + <#else> :lastPrimaryValue + + AND "C"."ID" > :lastId)) + <#else> + ("C"."${sortByColumn}" > + <#if sortByColumn == "LAST_RISKSCORE" > CAST(:lastPrimaryValue AS DOUBLE PRECISION) + <#else> :lastPrimaryValue + + OR ("C"."${sortByColumn}" = + <#if sortByColumn == "LAST_RISKSCORE" > CAST(:lastPrimaryValue AS DOUBLE PRECISION) + <#else>:lastPrimaryValue + + AND "C"."ID" > :lastId)) + + ) + <#elseif hasCursor && lastPrimaryValue?has_content && lastId?has_content> + AND ("C"."NAME" > :lastPrimaryValue + OR ("C"."NAME" = :lastPrimaryValue AND "C"."ID" > :lastId)) + + <#if sortByColumn?has_content> + ORDER BY "${sortByColumn}" ${sortDirection!"ASC"}, "ID" ASC + <#else> + <#-- Default sorting to ensure consistent pagination --> + ORDER BY "NAME" ASC, "ID" ASC + + LIMIT :limit + """) + @DefineNamedBindings + @RegisterRowMapper(ComponentListRowMapper.class) + @DefineApiProjectAclCondition(projectIdColumn = "\"C\".\"PROJECT_ID\"") + List listComponents( + @Define ArrayList whereConditions, + @BindMap Map queryParams, + @Bind int limit, + @Bind String lastPrimaryValue, + @Bind Long lastId, + @Define SortBy sortByColumn, + @Define String sortDirection, + @Define boolean hasCursor + ); + + enum SortBy { + NAME, + VERSION, + GROUP, + PURL, + CPE, + SWIDTAGID, + LAST_RISKSCORE + } + + enum HashType { + MD5, + SHA1, + SHA_256, + SHA_384, + SHA_512, + SHA3_256, + SHA3_384, + SHA3_512, + BLAKE2B_256, + BLAKE2B_384, + BLAKE2B_512, + BLAKE3 + } + class ComponentListRowMapper implements RowMapper { private final RowMapper componentRowMapper = BeanMapper.of(Component.class); @@ -200,8 +476,18 @@ class ComponentListRowMapper implements RowMapper { @Override public Component 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) { + if (hasColumn(rs, "projectUuid") && rs.getString("projectUuid") != null) { + final var project = new Project(); + project.setUuid(UUID.fromString(rs.getString("projectUuid"))); + maybeSet(rs, "projectName", ResultSet::getString, project::setName); + maybeSet(rs, "projectVersion", ResultSet::getString, project::setVersion); + component.setProject(project); + } + maybeSet(rs, "PURL", ResultSet::getString, component::setPurl); + if (rs.getString("LAST_RISKSCORE") != null) { + maybeSet(rs, "LAST_RISKSCORE", ResultSet::getDouble, component::setLastInheritedRiskScore); + } + if (hasColumn(rs, "licenseUuid") && rs.getString("licenseUuid") != null) { final var license = new License(); license.setUuid(UUID.fromString(rs.getString("licenseUuid"))); maybeSet(rs, "licenseId", ResultSet::getString, license::setLicenseId); @@ -211,7 +497,9 @@ public Component map(final ResultSet rs, final StatementContext ctx) throws SQLE maybeSet(rs, "isOsiApproved", ResultSet::getBoolean, license::setOsiApproved); component.setResolvedLicense(license); } - maybeSet(rs, "occurrenceCount", ResultSet::getLong, component::setOccurrenceCount); + if (hasColumn(rs, "occurrenceCount")) { + maybeSet(rs, "occurrenceCount", ResultSet::getLong, component::setOccurrenceCount); + } return component; } } diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/PaginationSupport.java b/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/PaginationSupport.java index c4702bdb85..d74bd183c5 100644 --- a/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/PaginationSupport.java +++ b/apiserver/src/main/java/org/dependencytrack/persistence/jdbi/PaginationSupport.java @@ -68,10 +68,37 @@ default TotalCount getBoundedTotalCount( String fromWhereClause, @Nullable Map whereParams, int threshold) { + return getBoundedTotalCount(fromWhereClause, whereParams, threshold, null); + } + + /** + * Calculates a bounded total count while applying the API project ACL condition using the provided project ID column. + * The {@code fromWhereClause} must already contain a {@code WHERE} clause; this method simply appends + * {@code AND ${apiProjectAclCondition}} to it. + */ + default TotalCount getBoundedTotalCountWithProjectAcl( + String fromWhereClause, + @Nullable Map whereParams, + int threshold, + String projectIdColumn) { + requireNonNull(projectIdColumn, "projectIdColumn must not be null"); + return getBoundedTotalCount(fromWhereClause, whereParams, threshold, projectIdColumn); + } + + private TotalCount getBoundedTotalCount( + String fromWhereClause, + @Nullable Map whereParams, + int threshold, + @Nullable String projectIdColumn) { requireNonNull(fromWhereClause, "fromWhereClause must not be null"); if (threshold < 1) { throw new IllegalArgumentException("threshold must not be less than 1"); } + if (projectIdColumn != null && projectIdColumn.isEmpty()) { + throw new IllegalArgumentException("ACL column must not be blank"); + } + + final boolean includeAcl = projectIdColumn != null; // NB: The limit is only effective when used on a subquery. // SELECT COUNT(*) ... LIMIT X is *not* sufficient: @@ -79,18 +106,29 @@ default TotalCount getBoundedTotalCount( final Query query = getHandle().createQuery(/* language=InjectedFreeMarker */ """ <#-- @ftlvariable name="fromWhereClause" type="String" --> <#-- @ftlvariable name="threshold" type="boolean" --> + <#-- @ftlvariable name="includeAcl" type="boolean" --> SELECT COUNT(*) FROM ( SELECT 1 ${fromWhereClause} + <#if includeAcl> + AND ${apiProjectAclCondition} + LIMIT (:threshold + 1) ) AS t """); + if (includeAcl) { + query.addCustomizer(new DefineApiProjectAclCondition.StatementCustomizer( + JdbiAttributes.ATTRIBUTE_API_PROJECT_ACL_CONDITION, + projectIdColumn)); + } + final long count = query .bindMap(whereParams) .bind("threshold", threshold) .define("fromWhereClause", fromWhereClause) + .define("includeAcl", includeAcl) .defineNamedBindings() .mapTo(long.class) .one(); diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/ComponentsResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/ComponentsResource.java index bde07eb6e3..9ff69fc7fc 100644 --- a/apiserver/src/main/java/org/dependencytrack/resources/v2/ComponentsResource.java +++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/ComponentsResource.java @@ -19,6 +19,9 @@ package org.dependencytrack.resources.v2; import alpine.server.auth.PermissionRequired; +import com.github.packageurl.MalformedPackageURLException; +import com.github.packageurl.PackageURL; +import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.ClientErrorException; import jakarta.ws.rs.NotAuthorizedException; import jakarta.ws.rs.NotFoundException; @@ -29,23 +32,37 @@ import org.apache.commons.lang3.StringUtils; import org.dependencytrack.api.v2.ComponentsApi; import org.dependencytrack.api.v2.model.CreateComponentRequest; +import org.dependencytrack.api.v2.model.ListComponentsResponse; +import org.dependencytrack.api.v2.model.ListComponentsResponseItem; +import org.dependencytrack.api.v2.model.SortDirection; import org.dependencytrack.auth.Permissions; +import org.dependencytrack.common.pagination.Page; import org.dependencytrack.exception.ProjectAccessDeniedException; import org.dependencytrack.model.Classifier; import org.dependencytrack.model.Component; import org.dependencytrack.model.License; import org.dependencytrack.model.Project; import org.dependencytrack.persistence.QueryManager; +import org.dependencytrack.persistence.jdbi.ComponentDao; +import org.dependencytrack.persistence.jdbi.ProjectDao; import org.dependencytrack.resources.AbstractApiResource; import org.dependencytrack.util.InternalComponentIdentifier; import org.dependencytrack.util.PurlUtil; import org.owasp.security.logging.SecurityMarkers; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import us.springett.parsers.cpe.CpeParser; +import us.springett.parsers.cpe.exceptions.CpeParsingException; import java.util.UUID; +import static org.dependencytrack.persistence.jdbi.JdbiFactory.inJdbiTransaction; +import static org.dependencytrack.resources.v2.mapping.ModelMapper.mapDependencyMetrics; +import static org.dependencytrack.resources.v2.mapping.ModelMapper.mapHashes; +import static org.dependencytrack.resources.v2.mapping.ModelMapper.mapLicense; import static org.dependencytrack.resources.v2.mapping.ModelMapper.mapOrganizationalContacts; +import static org.dependencytrack.resources.v2.mapping.ModelMapper.mapProject; +import static org.dependencytrack.resources.v2.mapping.ModelMapper.mapSortDirection; import static org.dependencytrack.util.PersistenceUtil.isUniqueConstraintViolation; @Provider @@ -62,7 +79,7 @@ public class ComponentsResource extends AbstractApiResource implements Component public Response createComponent(final CreateComponentRequest request) { final UUID projectUuid = request.getProjectUuid(); try (QueryManager qm = new QueryManager()) { - final Component componentCreated = qm.callInTransaction(() -> { + qm.callInTransaction(() -> { final Project project = qm.getObjectByUuid(Project.class, projectUuid); if (project == null) { throw new NotFoundException(); @@ -89,6 +106,78 @@ public Response createComponent(final CreateComponentRequest request) { } } + @Override + @PermissionRequired(Permissions.Constants.VIEW_PORTFOLIO) + public Response listComponents(UUID projectUuid, String group, String name, String version, String purl, String cpe, + String swidTagId, String hashType, String hash, Integer limit, String pageToken, SortDirection sortDirection, String sortBy) { + return inJdbiTransaction(getAlpineRequest(), handle -> { + Long projectId = null; + if (projectUuid != null) { + projectId = handle.attach(ProjectDao.class).getProjectId(projectUuid); + if (projectId == null) { + throw new NotFoundException(); + } + requireProjectAccess(handle, projectUuid); + } + PackageURL packageURL = null; + if (purl != null) { + try { + packageURL = new PackageURL(StringUtils.trimToNull(purl)); + } catch (MalformedPackageURLException e) { + throw new BadRequestException("Invalid package URL: %s".formatted(purl)); + } + } + if (cpe != null) { + try { + CpeParser.parse(StringUtils.trimToNull(cpe)); + } catch (CpeParsingException e) { + throw new BadRequestException("Invalid CPE: %s".formatted(cpe)); + } + } + ComponentDao.HashType hashTypeEnum = null; + if (hashType != null) { + try { + hashTypeEnum = ComponentDao.HashType.valueOf(StringUtils.trimToNull(hashType).toUpperCase()); + } catch (IllegalArgumentException e) { + throw new BadRequestException("Invalid Hash type: %s".formatted(hashType)); + } + } + final Page componentsPage = handle.attach(ComponentDao.class) + .listComponents(projectId, true, packageURL != null ? packageURL.canonicalize().toLowerCase() : null, StringUtils.trimToNull(cpe), + StringUtils.trimToNull(swidTagId), StringUtils.trimToNull(group), StringUtils.trimToNull(name), + StringUtils.trimToNull(version), hashTypeEnum, StringUtils.trimToNull(hash), limit, pageToken, sortBy, mapSortDirection(sortDirection)); + + final var response = ListComponentsResponse.builder() + .items(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())) + .purl(componentRow.getPurl() != null ? componentRow.getPurl().toString() : null) + .swidTagId(componentRow.getSwidTagId()) + .uuid(componentRow.getUuid()) + .version(componentRow.getVersion()) + .project(mapProject(componentRow.getProject())) + .metrics(mapDependencyMetrics(componentRow.getMetrics())) + .build()) + .toList()) + .nextPageToken(componentsPage.nextPageToken()) + .total(convertTotalCount(componentsPage.totalCount())) + .build(); + return Response.ok(response).build(); + }); + } + private Component mapRequestToComponent(CreateComponentRequest request, QueryManager qm, Project project) { final License resolvedLicense = qm.getLicense(request.getLicense()); final Component component = new Component(); 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 bf88bc226a..1ba17c17e8 100644 --- a/apiserver/src/main/java/org/dependencytrack/resources/v2/ProjectsResource.java +++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/ProjectsResource.java @@ -28,11 +28,11 @@ import org.dependencytrack.api.v2.model.CloneProjectInclude; import org.dependencytrack.api.v2.model.CloneProjectRequest; import org.dependencytrack.api.v2.model.CloneProjectResponse; -import org.dependencytrack.api.v2.model.ListComponentsResponse; -import org.dependencytrack.api.v2.model.ListComponentsResponseItem; import org.dependencytrack.api.v2.model.ListProjectAdvisoriesResponse; import org.dependencytrack.api.v2.model.ListProjectAdvisoriesResponseItem; import org.dependencytrack.api.v2.model.ListProjectAdvisoryFindingsResponseItem; +import org.dependencytrack.api.v2.model.ListProjectComponentsResponse; +import org.dependencytrack.api.v2.model.ListProjectComponentsResponseItem; import org.dependencytrack.auth.Permissions; import org.dependencytrack.common.pagination.Page; import org.dependencytrack.model.Component; @@ -74,10 +74,10 @@ public Response listProjectComponents(UUID uuid, Boolean onlyOutdated, Boolean o final Page componentsPage = handle.attach(ComponentDao.class) .listProjectComponents(projectId, onlyOutdated, onlyDirect, limit, pageToken); - final var response = ListComponentsResponse.builder() + final var response = ListProjectComponentsResponse.builder() .items(componentsPage.items().stream() - .map( - componentRow -> ListComponentsResponseItem.builder() + .map( + componentRow -> ListProjectComponentsResponseItem.builder() .name(componentRow.getName()) .hashes(mapHashes(componentRow)) .classifier(componentRow.getClassifier() != null ? componentRow.getClassifier().name() : null) diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/WorkflowsResource.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/WorkflowsResource.java index 425d28613c..8684b25e18 100644 --- a/apiserver/src/main/java/org/dependencytrack/resources/v2/WorkflowsResource.java +++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/WorkflowsResource.java @@ -64,6 +64,8 @@ import java.util.Set; import java.util.UUID; +import static org.dependencytrack.resources.v2.mapping.ModelMapper.mapSortDirection; + @Provider @NullMarked public class WorkflowsResource extends AbstractApiResource implements WorkflowsApi { @@ -155,7 +157,7 @@ public Response listWorkflowRuns( case "completed_at" -> ListWorkflowRunsRequest.SortBy.COMPLETED_AT; case null, default -> null; }) - .withSortDirection(convert(sortDirection)) + .withSortDirection(mapSortDirection(sortDirection)) .withPageToken(pageToken) .withLimit(limit)); @@ -199,7 +201,7 @@ public Response listWorkflowRunEvents( dexEngine.listRunHistory( new ListWorkflowRunHistoryRequest(id) .withFromSequenceNumber(fromSequenceNumber) - .withSortDirection(convert(sortDirection)) + .withSortDirection(mapSortDirection(sortDirection)) .withPageToken(pageToken) .withLimit(limit)); @@ -279,14 +281,4 @@ private static ListWorkflowRunEventsResponseItem convert( .event(eventJsonMap) .build(); } - - private static org.dependencytrack.common.pagination.@Nullable SortDirection convert( - @Nullable SortDirection sortDirection) { - return switch (sortDirection) { - case ASC -> org.dependencytrack.common.pagination.SortDirection.ASC; - case DESC -> org.dependencytrack.common.pagination.SortDirection.DESC; - case null -> null; - }; - } - } diff --git a/apiserver/src/main/java/org/dependencytrack/resources/v2/mapping/ModelMapper.java b/apiserver/src/main/java/org/dependencytrack/resources/v2/mapping/ModelMapper.java index 9bc2ca3f0e..ddf3efec57 100644 --- a/apiserver/src/main/java/org/dependencytrack/resources/v2/mapping/ModelMapper.java +++ b/apiserver/src/main/java/org/dependencytrack/resources/v2/mapping/ModelMapper.java @@ -18,10 +18,14 @@ */ package org.dependencytrack.resources.v2.mapping; +import org.dependencytrack.api.v2.model.ComponentProject; +import org.dependencytrack.api.v2.model.DependencyMetrics; import org.dependencytrack.api.v2.model.Hashes; import org.dependencytrack.api.v2.model.License; import org.dependencytrack.api.v2.model.OrganizationalContact; +import org.dependencytrack.api.v2.model.SortDirection; import org.dependencytrack.model.Component; +import org.jspecify.annotations.Nullable; import java.util.List; @@ -52,6 +56,51 @@ public static License mapLicense(org.dependencytrack.model.License license) { .build(); } + public static ComponentProject mapProject(org.dependencytrack.model.Project project) { + if (project == null) { + return null; + } + return ComponentProject.builder() + .name(project.getName()) + .version(project.getVersion()) + .uuid(project.getUuid()) + .build(); + } + + public static DependencyMetrics mapDependencyMetrics(org.dependencytrack.model.DependencyMetrics metrics) { + if (metrics == null) { + return null; + } + return DependencyMetrics.builder() + .critical(metrics.getCritical()) + .high(metrics.getHigh()) + .medium(metrics.getMedium()) + .low(metrics.getLow()) + .unassigned(metrics.getUnassigned()) + .vulnerabilities(metrics.getVulnerabilities()) + .suppressed(metrics.getSuppressed()) + .findingsTotal(metrics.getFindingsTotal()) + .findingsAudited(metrics.getFindingsAudited()) + .findingsUnaudited(metrics.getFindingsUnaudited()) + .inheritedRiskScore(metrics.getInheritedRiskScore()) + .policyViolationsFail(metrics.getPolicyViolationsFail()) + .policyViolationsWarn(metrics.getPolicyViolationsWarn()) + .policyViolationsInfo(metrics.getPolicyViolationsInfo()) + .policyViolationsTotal(metrics.getPolicyViolationsTotal()) + .policyViolationsAudited(metrics.getPolicyViolationsAudited()) + .policyViolationsUnaudited(metrics.getPolicyViolationsUnaudited()) + .policyViolationsSecurityTotal(metrics.getPolicyViolationsSecurityTotal()) + .policyViolationsSecurityAudited(metrics.getPolicyViolationsSecurityAudited()) + .policyViolationsSecurityUnaudited(metrics.getPolicyViolationsSecurityUnaudited()) + .policyViolationsLicenseTotal(metrics.getPolicyViolationsLicenseTotal()) + .policyViolationsLicenseAudited(metrics.getPolicyViolationsLicenseAudited()) + .policyViolationsLicenseUnaudited(metrics.getPolicyViolationsLicenseUnaudited()) + .policyViolationsOperationalTotal(metrics.getPolicyViolationsOperationalTotal()) + .policyViolationsOperationalAudited(metrics.getPolicyViolationsOperationalAudited()) + .policyViolationsOperationalUnaudited(metrics.getPolicyViolationsOperationalUnaudited()) + .build(); + } + public static Hashes mapHashes(Component component) { boolean hasAnyHash = component.getMd5() != null || component.getSha1() != null @@ -85,4 +134,13 @@ public static Hashes mapHashes(Component component) { .blake3(component.getBlake3()) .build(); } + + public static org.dependencytrack.common.pagination.@Nullable SortDirection mapSortDirection( + @Nullable SortDirection sortDirection) { + return switch (sortDirection) { + case ASC -> org.dependencytrack.common.pagination.SortDirection.ASC; + case DESC -> org.dependencytrack.common.pagination.SortDirection.DESC; + case null -> null; + }; + } } \ No newline at end of file diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v1/ComponentResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v1/ComponentResourceTest.java index 6146f484b5..1caa74714f 100644 --- a/apiserver/src/test/java/org/dependencytrack/resources/v1/ComponentResourceTest.java +++ b/apiserver/src/test/java/org/dependencytrack/resources/v1/ComponentResourceTest.java @@ -239,7 +239,7 @@ public void getComponentByIdentityWithCoordinatesTest() { componentB.setName("nameB"); componentB.setVersion("versionB"); componentB.setCpe("cpe:2.3:a:groupB:nameB:versionB:*:*:*:*:*:*:*"); - componentA.setPurl("pkg:maven/groupB/nameB@versionB?baz=qux"); + componentB.setPurl("pkg:maven/groupB/nameB@versionB?baz=qux"); componentB = qm.createComponent(componentB, false); final Response response = jersey.target(V1_COMPONENT + "/identity") diff --git a/apiserver/src/test/java/org/dependencytrack/resources/v2/ComponentsResourceTest.java b/apiserver/src/test/java/org/dependencytrack/resources/v2/ComponentsResourceTest.java index 28eb5d8e5d..b3cd19b749 100644 --- a/apiserver/src/test/java/org/dependencytrack/resources/v2/ComponentsResourceTest.java +++ b/apiserver/src/test/java/org/dependencytrack/resources/v2/ComponentsResourceTest.java @@ -18,15 +18,21 @@ */ package org.dependencytrack.resources.v2; +import jakarta.json.JsonObject; import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.core.Response; +import org.apache.http.HttpStatus; import org.dependencytrack.JerseyTestExtension; import org.dependencytrack.ResourceTest; import org.dependencytrack.auth.Permissions; +import org.dependencytrack.model.Component; +import org.dependencytrack.model.ComponentIdentity; import org.dependencytrack.model.Project; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import java.util.UUID; + import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; import static org.assertj.core.api.Assertions.assertThat; @@ -134,4 +140,451 @@ public void createComponentAclTest() { """.formatted(project.getUuid()))); assertThat(response.getStatus()).isEqualTo(201); } -} \ No newline at end of file + + @Test + public void listComponentsPaginationTest() { + prepareComponents(); + Response response = jersey.target("/components") + .queryParam("limit", 2) + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + JsonObject responseJson = parseJsonObject(response); + assertThatJson(responseJson.toString()).isEqualTo(/* language=JSON */ """ + { + "items" : [ { + "name": "nameA", + "version": "versionA", + "group": "groupA", + "cpe": "cpe:2.3:a:groupA:nameA:versionA:*:*:*:*:*:*:*", + "purl":"pkg:maven/groupA/nameA@versionA?foo=bar", + "internal": false, + "uuid": "${json-unit.any-string}", + "project": { + "name": "projectA", + "version": "1.0", + "uuid": "${json-unit.any-string}" + } + }, + { + "name": "nameB", + "version": "versionB", + "group": "groupB", + "cpe": "cpe:2.3:a:groupB:nameB:versionB:*:*:*:*:*:*:*", + "purl":"pkg:maven/groupB/nameB@versionB?baz=qux", + "internal": false, + "uuid": "${json-unit.any-string}", + "project": { + "name": "projectB", + "version": "1.0", + "uuid": "${json-unit.any-string}" + } + } + ], + "next_page_token": "${json-unit.any-string}", + "total": { + "count": 3, + "type": "EXACT" + } + } + """); + + final String nextPageToken = responseJson.getString("next_page_token"); + response = jersey.target("/components") + .queryParam("limit", 1) + .queryParam("page_token", nextPageToken) + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + responseJson = parseJsonObject(response); + assertThatJson(responseJson.toString()).isEqualTo(/* language=JSON */ """ + { + "items" : [ { + "name": "nameC", + "version": "versionC", + "group": "groupC", + "cpe": "cpe:2.3:a:groupC:nameC:versionC:*:*:*:*:*:*:*", + "purl":"pkg:maven/groupC/nameC@versionC?baz=qux", + "hashes": { + "sha1":"da39a3ee5e6b4b0d3255bfef95601890afd80709" + }, + "internal": false, + "last_inherited_risk_score": 2.3, + "uuid": "${json-unit.any-string}", + "project": { + "name": "projectB", + "version": "1.0", + "uuid": "${json-unit.any-string}" + } + } + ], + "total": { + "count": 3, + "type": "EXACT" + } + } + """); + } + + @Test + public void listComponentsSortingTest() { + prepareComponents(); + final Response response = jersey.target("/components") + .queryParam("limit", 3) + .queryParam("sort_by", "purl") + .queryParam("sort_direction", "DESC") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + JsonObject responseJson = parseJsonObject(response); + assertThatJson(responseJson.toString()).isEqualTo(/* language=JSON */ """ + { + "items" : [ { + "name": "nameC", + "version": "versionC", + "group": "groupC", + "cpe": "cpe:2.3:a:groupC:nameC:versionC:*:*:*:*:*:*:*", + "purl":"pkg:maven/groupC/nameC@versionC?baz=qux", + "hashes": { + "sha1":"da39a3ee5e6b4b0d3255bfef95601890afd80709" + }, + "internal": false, + "last_inherited_risk_score": 2.3, + "uuid": "${json-unit.any-string}", + "project": { + "name": "projectB", + "version": "1.0", + "uuid": "${json-unit.any-string}" + } + }, + { + "name": "nameB", + "version": "versionB", + "group": "groupB", + "cpe": "cpe:2.3:a:groupB:nameB:versionB:*:*:*:*:*:*:*", + "purl":"pkg:maven/groupB/nameB@versionB?baz=qux", + "internal": false, + "uuid": "${json-unit.any-string}", + "project": { + "name": "projectB", + "version": "1.0", + "uuid": "${json-unit.any-string}" + } + }, + { + "name": "nameA", + "version": "versionA", + "group": "groupA", + "cpe": "cpe:2.3:a:groupA:nameA:versionA:*:*:*:*:*:*:*", + "purl":"pkg:maven/groupA/nameA@versionA?foo=bar", + "internal": false, + "uuid": "${json-unit.any-string}", + "project": { + "name": "projectA", + "version": "1.0", + "uuid": "${json-unit.any-string}" + } + } + ], + "total": { + "count": 3, + "type": "EXACT" + } + } + """); + } + + @Test + public void listComponentsWithCoordinatesTest() { + prepareComponents(); + Response response = jersey.target("/components") + .queryParam("group", "B") + .queryParam("name", "B") + .queryParam("version", "versionB") + .queryParam("limit", 2) + .request() + .header(X_API_KEY, apiKey) + .get(); + + assertThat(response.getStatus()).isEqualTo(200); + final JsonObject responseJson = parseJsonObject(response); + assertThatJson(responseJson.toString()).isEqualTo(/* language=JSON */ """ + { + "items" : [ { + "name": "nameB", + "version": "versionB", + "group": "groupB", + "cpe": "cpe:2.3:a:groupB:nameB:versionB:*:*:*:*:*:*:*", + "purl":"pkg:maven/groupB/nameB@versionB?baz=qux", + "internal": false, + "uuid": "${json-unit.any-string}", + "project": { + "name": "projectB", + "version": "1.0", + "uuid": "${json-unit.any-string}" + } + } + ], + "total": { + "count": 1, + "type": "EXACT" + } + } + """); + } + + @Test + public void listComponentsWithPurlTest() { + prepareComponents(); + Response response = jersey.target("/components") + .queryParam("purl", "pkg:maven/groupB/nameB@versionB") + .queryParam("limit", 2) + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + final JsonObject responseJson = parseJsonObject(response); + assertThatJson(responseJson.toString()).isEqualTo(/* language=JSON */ """ + { + "items" : [ { + "name": "nameB", + "version": "versionB", + "group": "groupB", + "cpe": "cpe:2.3:a:groupB:nameB:versionB:*:*:*:*:*:*:*", + "purl":"pkg:maven/groupB/nameB@versionB?baz=qux", + "internal": false, + "uuid": "${json-unit.any-string}", + "project": { + "name": "projectB", + "version": "1.0", + "uuid": "${json-unit.any-string}" + } + } + ], + "total": { + "count": 1, + "type": "EXACT" + } + } + """); + } + + @Test + public void listComponentsWithInvalidCpeTest() { + prepareComponents(); + Response response = jersey.target("/components") + .queryParam("cpe", "nameB") + .queryParam("limit", 2) + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(HttpStatus.SC_BAD_REQUEST); + final JsonObject responseJson = parseJsonObject(response); + assertThat(responseJson.toString()).contains("Invalid CPE: nameB"); + } + + @Test + public void listComponentsWithCpeTest() { + prepareComponents(); + Response response = jersey.target("/components") + .queryParam("cpe", "cpe:2.3:a:groupB:nameB:versionB:*:*:*:*:*:*:*") + .queryParam("limit", 2) + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + final JsonObject responseJson = parseJsonObject(response); + assertThatJson(responseJson.toString()).isEqualTo(/* language=JSON */ """ + { + "items" : [ { + "name": "nameB", + "version": "versionB", + "group": "groupB", + "cpe": "cpe:2.3:a:groupB:nameB:versionB:*:*:*:*:*:*:*", + "purl":"pkg:maven/groupB/nameB@versionB?baz=qux", + "internal": false, + "uuid": "${json-unit.any-string}", + "project": { + "name": "projectB", + "version": "1.0", + "uuid": "${json-unit.any-string}" + } + } + ], + "total": { + "count": 1, + "type": "EXACT" + } + } + """); + } + + @Test + public void listComponentsWithProjectTest() { + prepareComponents(); + Response response = jersey.target("/components") + .queryParam("project_uuid", qm.getProject("projectA", "1.0").getUuid()) + .queryParam("limit", 2) + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + final JsonObject responseJson = parseJsonObject(response); + assertThatJson(responseJson.toString()).isEqualTo(/* language=JSON */ """ + { + "items" : [ { + "name": "nameA", + "version": "versionA", + "group": "groupA", + "cpe": "cpe:2.3:a:groupA:nameA:versionA:*:*:*:*:*:*:*", + "purl":"pkg:maven/groupA/nameA@versionA?foo=bar", + "internal": false, + "uuid": "${json-unit.any-string}", + "project": { + "name": "projectA", + "version": "1.0", + "uuid": "${json-unit.any-string}" + } + } + ], + "total": { + "count": 1, + "type": "EXACT" + } + } + """); + } + + @Test + public void listComponentsWithProjectWhenProjectDoesNotExistTest() { + prepareComponents(); + Response response = jersey.target("/components") + .queryParam("project_uuid", UUID.randomUUID()) + .queryParam("limit", 2) + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(HttpStatus.SC_NOT_FOUND); + assertThat(response.getHeaderString(TOTAL_COUNT_HEADER)).isNull(); + assertThat(getPlainTextBody(response)).contains("Not Found"); + } + + @Test + public void listComponentsAclTest() { + enablePortfolioAccessControl(); + initializeWithPermissions(Permissions.PORTFOLIO_MANAGEMENT); + prepareComponents(); + Response response = jersey.target("/components") + .queryParam("name", "name") + .queryParam("limit", 2) + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + final JsonObject responseJson = parseJsonObject(response); + assertThatJson(responseJson.toString()).isEqualTo(/* language=JSON */ """ + { + "items" : [ { + "name": "nameA", + "version": "versionA", + "group": "groupA", + "cpe": "cpe:2.3:a:groupA:nameA:versionA:*:*:*:*:*:*:*", + "purl":"pkg:maven/groupA/nameA@versionA?foo=bar", + "internal": false, + "uuid": "${json-unit.any-string}", + "project": { + "name": "projectA", + "version": "1.0", + "uuid": "${json-unit.any-string}" + } + } + ], + "total": { + "count": 1, + "type": "EXACT" + } + } + """); + } + + @Test + public void listComponentByHashTest() { + prepareComponents(); + Response response = jersey.target("/components") + .queryParam("hash_type", "SHA1") + .queryParam("hash", "da39a3ee5e6b4b0d3255bfef95601890afd80709") + .queryParam("limit", 2) + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + final JsonObject responseJson = parseJsonObject(response); + assertThatJson(responseJson.toString()).isEqualTo(/* language=JSON */ """ + { + "items" : [ { + "name": "nameC", + "version": "versionC", + "group": "groupC", + "cpe": "cpe:2.3:a:groupC:nameC:versionC:*:*:*:*:*:*:*", + "purl":"pkg:maven/groupC/nameC@versionC?baz=qux", + "hashes": { + "sha1":"da39a3ee5e6b4b0d3255bfef95601890afd80709" + }, + "internal": false, + "last_inherited_risk_score": 2.3, + "uuid": "${json-unit.any-string}", + "project": { + "name": "projectB", + "version": "1.0", + "uuid": "${json-unit.any-string}" + } + } + ], + "total": { + "count": 1, + "type": "EXACT" + } + } + """); + } + + private void prepareComponents() { + initializeWithPermissions(Permissions.VIEW_PORTFOLIO); + + final Project projectA = qm.createProject("projectA", null, "1.0", null, null, null, null, false); + projectA.addAccessTeam(team); + var componentA = new Component(); + componentA.setProject(projectA); + componentA.setGroup("groupA"); + componentA.setName("nameA"); + componentA.setVersion("versionA"); + componentA.setCpe("cpe:2.3:a:groupA:nameA:versionA:*:*:*:*:*:*:*"); + componentA.setPurl("pkg:maven/groupA/nameA@versionA?foo=bar"); + qm.createComponent(componentA, false); + projectA.setDirectDependencies("[%s]".formatted(new ComponentIdentity(componentA).toJSON())); + + final Project projectB = qm.createProject("projectB", null, "1.0", null, null, null, null, false); + var componentB = new Component(); + componentB.setProject(projectB); + componentB.setGroup("groupB"); + componentB.setName("nameB"); + componentB.setVersion("versionB"); + componentB.setCpe("cpe:2.3:a:groupB:nameB:versionB:*:*:*:*:*:*:*"); + componentB.setPurl("pkg:maven/groupB/nameB@versionB?baz=qux"); + qm.createComponent(componentB, false); + + var componentC = new Component(); + componentC.setProject(projectB); + componentC.setGroup("groupC"); + componentC.setName("nameC"); + componentC.setVersion("versionC"); + componentC.setCpe("cpe:2.3:a:groupC:nameC:versionC:*:*:*:*:*:*:*"); + componentC.setPurl("pkg:maven/groupC/nameC@versionC?baz=qux"); + componentC.setSha1("da39a3ee5e6b4b0d3255bfef95601890afd80709"); + componentC.setLastInheritedRiskScore(2.3); + qm.createComponent(componentC, false); + } +}