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
+ #if>
+ OR ("C"."${sortByColumn}" =
+ <#if sortByColumn == "LAST_RISKSCORE" > CAST(:lastPrimaryValue AS DOUBLE PRECISION)
+ <#else> :lastPrimaryValue
+ #if>
+ AND "C"."ID" > :lastId))
+ <#else>
+ ("C"."${sortByColumn}" >
+ <#if sortByColumn == "LAST_RISKSCORE" > CAST(:lastPrimaryValue AS DOUBLE PRECISION)
+ <#else> :lastPrimaryValue
+ #if>
+ OR ("C"."${sortByColumn}" =
+ <#if sortByColumn == "LAST_RISKSCORE" > CAST(:lastPrimaryValue AS DOUBLE PRECISION)
+ <#else>:lastPrimaryValue
+ #if>
+ AND "C"."ID" > :lastId))
+ #if>
+ )
+ <#elseif hasCursor && lastPrimaryValue?has_content && lastId?has_content>
+ AND ("C"."NAME" > :lastPrimaryValue
+ OR ("C"."NAME" = :lastPrimaryValue AND "C"."ID" > :lastId))
+ #if>
+ <#if sortByColumn?has_content>
+ ORDER BY "${sortByColumn}" ${sortDirection!"ASC"}, "ID" ASC
+ <#else>
+ <#-- Default sorting to ensure consistent pagination -->
+ ORDER BY "NAME" ASC, "ID" ASC
+ #if>
+ 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}
+ #if>
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);
+ }
+}