diff --git a/src/main/java/org/dependencytrack/model/Project.java b/src/main/java/org/dependencytrack/model/Project.java index 9d4c3a0019..41f9446d62 100644 --- a/src/main/java/org/dependencytrack/model/Project.java +++ b/src/main/java/org/dependencytrack/model/Project.java @@ -266,7 +266,7 @@ public enum FetchGroup { @Persistent @Column(name = "PARENT_PROJECT_ID") - @JsonIncludeProperties(value = {"name", "version", "uuid"}) + @JsonIncludeProperties(value = {"name", "version", "uuid", "parent"}) private Project parent; @Persistent(mappedBy = "parent") diff --git a/src/main/java/org/dependencytrack/persistence/ComponentQueryManager.java b/src/main/java/org/dependencytrack/persistence/ComponentQueryManager.java index 9d5b542a4e..09c82b0d3f 100644 --- a/src/main/java/org/dependencytrack/persistence/ComponentQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/ComponentQueryManager.java @@ -47,6 +47,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; @@ -322,6 +323,13 @@ public PaginatedResult getComponents(ComponentIdentity identity, Project project component.getProject().getCpe(); component.getProject().getUuid(); } + // Populate ancestor paths for all component projects + final List componentProjects = result.getList(Component.class).stream() + .map(Component::getProject) + .filter(Objects::nonNull) + .distinct() + .toList(); + populateAncestorPaths(componentProjects); return result; } diff --git a/src/main/java/org/dependencytrack/persistence/FindingsSearchQueryManager.java b/src/main/java/org/dependencytrack/persistence/FindingsSearchQueryManager.java index 72fbedc7ad..f6bd8723e4 100644 --- a/src/main/java/org/dependencytrack/persistence/FindingsSearchQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/FindingsSearchQueryManager.java @@ -27,6 +27,7 @@ import org.dependencytrack.model.Component; import org.dependencytrack.model.Finding; import org.dependencytrack.model.GroupedFinding; +import org.dependencytrack.model.Project; import org.dependencytrack.model.RepositoryMetaComponent; import org.dependencytrack.model.RepositoryType; import org.dependencytrack.model.Vulnerability; @@ -34,11 +35,15 @@ import javax.jdo.PersistenceManager; import javax.jdo.Query; +import org.dependencytrack.resources.v1.vo.AffectedProject; + import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; public class FindingsSearchQueryManager extends QueryManager implements IQueryManager { @@ -157,10 +162,37 @@ public PaginatedResult getAllFindings(final Map filters, final b } findings.add(finding); } + populateParentChains(findings); result.setObjects(findings); return result; } + /** + * Populates the parent chain for each finding's project so the UI can render tooltips + * (e.g. "Root > Parent > Project" for nested project hierarchies). + */ + private void populateParentChains(List findings) { + final Set projectUuids = findings.stream() + .map(f -> (String) f.getComponent().get("project")) + .filter(s -> s != null && !s.isBlank()) + .map(UUID::fromString) + .collect(Collectors.toSet()); + if (projectUuids.isEmpty()) { + return; + } + final Map projectMap = getProjectsWithAncestorPaths(projectUuids); + for (final Finding finding : findings) { + final String projectUuidStr = (String) finding.getComponent().get("project"); + if (projectUuidStr == null) { + continue; + } + final Project project = projectMap.get(UUID.fromString(projectUuidStr)); + if (project != null) { + finding.getComponent().put("parent", AffectedProject.buildParentInfo(project.getParent())); + } + } + } + /** * Returns a List of all Finding objects filtered by ACL and other optional filters. The resulting list is grouped by vulnerability. * @param filters determines the filters to apply on the list of Finding objects diff --git a/src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java b/src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java index 3dc6d3a9fa..6c9c036d06 100644 --- a/src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/PolicyQueryManager.java @@ -419,6 +419,11 @@ public PaginatedResult getPolicyViolations(final Project project, boolean includ violation.getComponent().getResolvedLicense(); // force resolved license to ne included since its not the default violation.setAnalysis(getViolationAnalysis(violation.getComponent(), violation)); // Include the violation analysis by default } + populateAncestorPaths(result.getList(PolicyViolation.class).stream() + .map(PolicyViolation::getProject) + .filter(Objects::nonNull) + .distinct() + .toList()); return result; } @@ -444,6 +449,11 @@ public PaginatedResult getPolicyViolations(final Component component, boolean in violation.getComponent().getResolvedLicense(); // force resolved license to ne included since its not the default violation.setAnalysis(getViolationAnalysis(violation.getComponent(), violation)); // Include the violation analysis by default } + populateAncestorPaths(result.getList(PolicyViolation.class).stream() + .map(PolicyViolation::getProject) + .filter(Objects::nonNull) + .distinct() + .toList()); return result; } @@ -475,6 +485,11 @@ public PaginatedResult getPolicyViolations(boolean includeSuppressed, boolean sh violation.getComponent().getResolvedLicense(); // force resolved license to be included since it's not the default violation.setAnalysis(getViolationAnalysis(violation.getComponent(), violation)); // Include the violation analysis by default } + populateAncestorPaths(result.getList(PolicyViolation.class).stream() + .map(PolicyViolation::getProject) + .filter(Objects::nonNull) + .distinct() + .toList()); return result; } diff --git a/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java b/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java index 3f2fbd8d52..550a60b49c 100644 --- a/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java @@ -157,6 +157,11 @@ public PaginatedResult getProjects(final boolean includeMetrics, final boolean e project.setMetrics(getMostRecentProjectMetrics(project)); } } + if (!onlyRoot) { + // When not showing only root projects (e.g., during search), populate the ancestor path + // to provide hierarchy context in the flat list view. + populateAncestorPaths(result.getList(Project.class)); + } return result; } @@ -223,6 +228,7 @@ public Project getProject(final String uuid) { project.setMetrics(getMostRecentProjectMetrics(project)); // set ProjectVersions to minimize the number of round trips a client needs to make project.setVersions(getProjectVersions(project)); + populateAncestorPaths(List.of(project)); } return project; } @@ -254,6 +260,7 @@ public Project getProject(final String name, final String version) { project.setMetrics(getMostRecentProjectMetrics(project)); // set ProjectVersions to prevent extra round trip project.setVersions(getProjectVersions(project)); + populateAncestorPaths(List.of(project)); } return project; } @@ -286,6 +293,7 @@ public Project getLatestProjectVersion(final String name) { project.setMetrics(getMostRecentProjectMetrics(project)); // set ProjectVersions to prevent extra round trip project.setVersions(getProjectVersions(project)); + populateAncestorPaths(List.of(project)); } return project; } @@ -1032,13 +1040,13 @@ public void deleteProjectsByUUIDs(Collection uuids) { ); executeAndCloseWithArray(sqlQuery, queryParameter); - sqlQuery = pm.newQuery(JDOQuery.SQL_QUERY_LANGUAGE, """ + sqlQuery = pm.newQuery(JDOQuery.SQL_QUERY_LANGUAGE, """ DELETE FROM "DEPENDENCYMETRICS" WHERE "PROJECT_ID" = ANY(?); """.replaceAll(Pattern.quote("= ANY(?)"), inExpression) ); executeAndCloseWithArray(sqlQuery, queryParameter); - sqlQuery = pm.newQuery(JDOQuery.SQL_QUERY_LANGUAGE, """ + sqlQuery = pm.newQuery(JDOQuery.SQL_QUERY_LANGUAGE, """ DELETE FROM "FINDINGATTRIBUTION" WHERE "PROJECT_ID" = ANY(?); """.replaceAll(Pattern.quote("= ANY(?)"), inExpression) ); @@ -1060,13 +1068,13 @@ public void deleteProjectsByUUIDs(Collection uuids) { ); executeAndCloseWithArray(sqlQuery, queryParameter); - sqlQuery = pm.newQuery(JDOQuery.SQL_QUERY_LANGUAGE, """ + sqlQuery = pm.newQuery(JDOQuery.SQL_QUERY_LANGUAGE, """ DELETE FROM "ANALYSIS" WHERE "PROJECT_ID" = ANY(?); """.replace("= ANY(?)", inExpression) ); executeAndCloseWithArray(sqlQuery, queryParameter); - sqlQuery = pm.newQuery(JDOQuery.SQL_QUERY_LANGUAGE, """ + sqlQuery = pm.newQuery(JDOQuery.SQL_QUERY_LANGUAGE, """ DELETE FROM "COMPONENT_PROPERTY" WHERE "COMPONENT_ID" IN ( SELECT "ID" FROM "COMPONENT" WHERE "PROJECT_ID" = ANY(?) ); @@ -1119,7 +1127,7 @@ WHERE PROJECT.ID IN (SELECT value FROM STRING_SPLIT(?, ',')) executeAndCloseWithArray(sqlQuery, queryParameter); } - sqlQuery = pm.newQuery(JDOQuery.SQL_QUERY_LANGUAGE, """ + sqlQuery = pm.newQuery(JDOQuery.SQL_QUERY_LANGUAGE, """ DELETE FROM "COMPONENT" WHERE "PROJECT_ID" = ANY(?); """.replace("= ANY(?)", inExpression) ); @@ -1318,7 +1326,7 @@ WHERE PROJECT.ID IN (SELECT value FROM STRING_SPLIT(?, ',')) executeAndCloseWithArray(sqlQuery, queryParameter); } - sqlQuery = pm.newQuery(JDOQuery.SQL_QUERY_LANGUAGE, """ + sqlQuery = pm.newQuery(JDOQuery.SQL_QUERY_LANGUAGE, """ DELETE FROM "PROJECT" WHERE "ID" = ANY(?); """.replace("= ANY(?)", inExpression) ); @@ -1634,6 +1642,7 @@ public PaginatedResult getChildrenProjects(final UUID uuid, final boolean includ preprocessACLs(query, queryFilter, params, false); query.getFetchPlan().addGroup(Project.FetchGroup.ALL.name()); result = execute(query, params); + populateAncestorPaths(result.getList(Project.class)); if (includeMetrics) { // Populate each Project object in the paginated result with transitive related // data to minimize the number of round trips a client needs to make, process, and render. @@ -1663,6 +1672,7 @@ public PaginatedResult getChildrenProjects(final Classifier classifier, final UU preprocessACLs(query, queryFilter, params, false); query.getFetchPlan().addGroup(Project.FetchGroup.ALL.name()); result = execute(query, params); + populateAncestorPaths(result.getList(Project.class)); if (includeMetrics) { // Populate each Project object in the paginated result with transitive related // data to minimize the number of round trips a client needs to make, process, and render. @@ -1695,7 +1705,9 @@ public PaginatedResult getChildrenProjects(final Tag tag, final UUID uuid, final final Map params = filterBuilder.getParams(); preprocessACLs(query, queryFilter, params, false); + query.getFetchPlan().addGroup(Project.FetchGroup.ALL.name()); result = execute(query, params); + populateAncestorPaths(result.getList(Project.class)); if (includeMetrics) { // Populate each Project object in the paginated result with transitive related // data to minimize the number of round trips a client needs to make, process, and render. @@ -1811,6 +1823,24 @@ public boolean doesProjectExist(final String name, final String version) { } } + /** + * Fetches projects by their UUIDs and populates their ancestor paths. + * Returns a map of project UUID to project for efficient lookup. + * + * @param uuids project UUIDs to fetch + * @return map of project UUID to project with ancestor chains wired + */ + public Map getProjectsWithAncestorPaths(Collection uuids) { + if (uuids == null || uuids.isEmpty()) { + return Map.of(); + } + final List projects = fetchProjectsByUuids(Set.copyOf(uuids)); + populateAncestorPaths(projects); + return projects.stream() + .filter(p -> p.getUuid() != null) + .collect(Collectors.toMap(Project::getUuid, p -> p, (a, b) -> a)); + } + private static boolean isChildOf(Project project, UUID uuid) { boolean isChild = false; if (project.getParent() != null){ diff --git a/src/main/java/org/dependencytrack/persistence/QueryManager.java b/src/main/java/org/dependencytrack/persistence/QueryManager.java index c099017b9c..def4daf1cb 100644 --- a/src/main/java/org/dependencytrack/persistence/QueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/QueryManager.java @@ -99,6 +99,8 @@ import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; import static org.datanucleus.PropertyNames.PROPERTY_QUERY_SQL_ALLOWALL; import static org.dependencytrack.model.ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED; @@ -445,6 +447,10 @@ public boolean doesProjectExist(final String name, final String version) { return getProjectQueryManager().doesProjectExist(name, version); } + public Map getProjectsWithAncestorPaths(final Collection uuids) { + return getProjectQueryManager().getProjectsWithAncestorPaths(uuids); + } + public Tag getTagByName(final String name) { return getTagQueryManager().getTagByName(name); } @@ -1671,4 +1677,111 @@ public String getOffsetLimitSqlClause() { return clauseTemplate.formatted(pagination.getOffset(), pagination.getLimit()); } + /** + * Maximum depth for ancestor path traversal to prevent infinite loops + * in case of circular references or excessively deep hierarchies. + */ + protected static final int MAX_ANCESTOR_DEPTH = 25; + + /** + * Populates the ancestor path for all projects in the list using batch fetching. + * This is more efficient than traversing each project's hierarchy individually, + * as it fetches all ancestors level by level in O(depth) queries instead of O(N*depth). + * + * @param projects the list of projects to populate ancestor paths for + */ + protected void populateAncestorPaths(List projects) { + if (projects == null || projects.isEmpty()) { + return; + } + + // Build a map of all projects by UUID for quick lookup + final Map projectMap = projects.stream() + .filter(p -> p.getUuid() != null) + .collect(Collectors.toMap(Project::getUuid, Function.identity(), (a, b) -> a)); + + // Collect parent UUIDs that we need to fetch (not already in our map) + Set parentUuidsToFetch = projects.stream() + .map(Project::getParent) + .filter(parent -> parent != null && parent.getUuid() != null) + .filter(parent -> !projectMap.containsKey(parent.getUuid())) + .map(Project::getUuid) + .collect(Collectors.toSet()); + + // Fetch ancestors level by level until we have all of them (with depth limit for safety) + int depth = 0; + while (!parentUuidsToFetch.isEmpty() && depth < MAX_ANCESTOR_DEPTH) { + final List fetchedParents = fetchProjectsByUuids(parentUuidsToFetch); + + // Add fetched parents to map and collect next level of parent UUIDs + fetchedParents.forEach(parent -> projectMap.put(parent.getUuid(), parent)); + + parentUuidsToFetch = fetchedParents.stream() + .map(Project::getParent) + .filter(parent -> parent != null && parent.getUuid() != null) + .filter(parent -> !projectMap.containsKey(parent.getUuid())) + .map(Project::getUuid) + .collect(Collectors.toSet()); + + depth++; + } + + // Wire the parent chain for each project so serialization produces nested parent structure + projects.forEach(project -> { + final List path = buildParentChainFromMap(project, projectMap); + wireParentChain(path); + }); + } + + /** + * Wires the parent chain on the Project entities so that serialization produces a nested + * parent structure (parent containing parent containing ...). Path is ordered from root to immediate parent. + */ + protected static void wireParentChain(List path) { + if (path == null || path.size() < 2) { + return; + } + for (int i = 1; i < path.size(); i++) { + path.get(i).setParent(path.get(i - 1)); + } + path.get(0).setParent(null); + } + + /** + * Fetches projects by their UUIDs in a single query. + */ + protected List fetchProjectsByUuids(Set uuids) { + if (uuids == null || uuids.isEmpty()) { + return List.of(); + } + final Query query = pm.newQuery(Project.class); + query.setFilter(":uuids.contains(uuid)"); + query.setParameters(List.copyOf(uuids)); + try { + return List.copyOf(query.executeList()); + } finally { + query.closeAll(); + } + } + + /** + * Builds the parent chain for a project using a pre-populated map of projects. + * The path is ordered from root to immediate parent (not including the project itself). + */ + protected static List buildParentChainFromMap(Project project, Map projectMap) { + final List path = new ArrayList<>(); + Project current = project.getParent(); + int depth = 0; + + while (current != null && depth < MAX_ANCESTOR_DEPTH) { + final Project resolved = projectMap.get(current.getUuid()); + if (resolved != null) { + path.addFirst(resolved); + } + current = current.getParent() != null ? projectMap.get(current.getParent().getUuid()) : null; + depth++; + } + return path; + } + } \ No newline at end of file diff --git a/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java b/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java index 7aeee2d765..0cc0ea9199 100644 --- a/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java @@ -511,11 +511,12 @@ private String generateExcludeSuppressed(Project project) { * @return a List of AffectedProjects */ public List getAffectedProjects(Vulnerability vulnerability) { - final Map affectedProjectMap = new HashMap<>(); + // First pass: collect unique projects and their affected component UUIDs + final Map projectMap = new HashMap<>(); + final Map> affectedComponentUuidsMap = new HashMap<>(); - final List projects = new ArrayList<>(); for (final Component component: vulnerability.getComponents()) { - if (! super.hasAccess(super.principal, component.getProject())) { + if (!super.hasAccess(super.principal, component.getProject())) { continue; } boolean affected = true; @@ -525,21 +526,28 @@ public List getAffectedProjects(Vulnerability vulnerability) { } if (affected) { Project project = component.getProject(); - AffectedProject affectedProject = affectedProjectMap.get(project.getUuid()); - if(affectedProject == null) { - affectedProject = new AffectedProject( - project.getUuid(), - project.getDirectDependencies() != null, - project.getName(), - project.getVersion(), - project.isActive(), - null); - affectedProjectMap.put(project.getUuid(), affectedProject); - } - affectedProject.getAffectedComponentUuids().add(component.getUuid()); + projectMap.putIfAbsent(project.getUuid(), project); + affectedComponentUuidsMap + .computeIfAbsent(project.getUuid(), k -> new ArrayList<>()) + .add(component.getUuid()); } } - return affectedProjectMap.values().stream().toList(); + + // Wire parent chains for all affected projects + final List projects = new ArrayList<>(projectMap.values()); + populateAncestorPaths(projects); + + // Create AffectedProject objects with nested parent structure + return projects.stream() + .map(project -> new AffectedProject( + project.getUuid(), + project.getDirectDependencies() != null, + project.getName(), + project.getVersion(), + project.isActive(), + affectedComponentUuidsMap.get(project.getUuid()), + AffectedProject.buildParentInfo(project.getParent()))) + .toList(); } public synchronized VulnerabilityAlias synchronizeVulnerabilityAlias(final VulnerabilityAlias alias) { diff --git a/src/main/java/org/dependencytrack/resources/v1/PolicyViolationResource.java b/src/main/java/org/dependencytrack/resources/v1/PolicyViolationResource.java index d7dc7e284c..b12848c047 100644 --- a/src/main/java/org/dependencytrack/resources/v1/PolicyViolationResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/PolicyViolationResource.java @@ -208,6 +208,12 @@ public Response getViolationsByComponent(@Parameter(description = "The UUID of t *

* This ensures that responses include not only the violations themselves, but also the associated * {@link org.dependencytrack.model.Policy}, which is required to tell the policy name and violation state. + *

+ * Parent path limitation: Only the direct parent (one level) is included in the project. A full + * ancestor chain is difficult to support here because: (1) Higher JDO fetch depth triggers cycles + * (Project↔children, Component↔project) and causes "Input is too deeply nested" serialization errors; + * (2) This API returns raw JDO entities—a proper full chain would require a response VO with + * {@link org.dependencytrack.resources.v1.vo.ProjectParentInfo} (like {@code AffectedProject}), i.e. an API contract change. * * @param qm The {@link QueryManager} to use * @param violations The {@link PolicyViolation}s to detach @@ -216,7 +222,7 @@ public Response getViolationsByComponent(@Parameter(description = "The UUID of t */ private Collection detachViolations(final QueryManager qm, final Collection violations) { final PersistenceManager pm = qm.getPersistenceManager(); - pm.getFetchPlan().setMaxFetchDepth(2); // Ensure policy is included + pm.getFetchPlan().setMaxFetchDepth(2); // Policy + project + direct parent only; higher depth triggers cycles pm.getFetchPlan().setDetachmentOptions(FetchPlan.DETACH_LOAD_FIELDS); return qm.getPersistenceManager().detachCopyAll(violations); } diff --git a/src/main/java/org/dependencytrack/resources/v1/TagResource.java b/src/main/java/org/dependencytrack/resources/v1/TagResource.java index 67c97b7c6e..86994aa55f 100644 --- a/src/main/java/org/dependencytrack/resources/v1/TagResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/TagResource.java @@ -43,6 +43,7 @@ import org.dependencytrack.resources.v1.openapi.PaginatedApi; import org.dependencytrack.resources.v1.problems.ProblemDetails; import org.dependencytrack.resources.v1.problems.TagOperationProblemDetails; +import org.dependencytrack.resources.v1.vo.AffectedProject; import org.dependencytrack.resources.v1.vo.TagListResponseItem; import org.dependencytrack.resources.v1.vo.TaggedCollectionProjectListResponseItem; import org.dependencytrack.resources.v1.vo.TaggedNotificationRuleListResponseItem; @@ -62,9 +63,12 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; +import org.dependencytrack.model.Project; + @Path("/v1/tag") @io.swagger.v3.oas.annotations.tags.Tag(name = "tag") @SecurityRequirements({ @@ -201,12 +205,24 @@ public Response getTaggedProjects( // Will likely need a migration to cleanup existing tags for this. final List taggedProjectListRows; + final Map projectMap; try (final var qm = new QueryManager(getAlpineRequest())) { taggedProjectListRows = qm.getTaggedProjects(tagName); + final var uuids = taggedProjectListRows.stream() + .map(row -> UUID.fromString(row.uuid())) + .toList(); + projectMap = qm.getProjectsWithAncestorPaths(uuids); } final List tags = taggedProjectListRows.stream() - .map(row -> new TaggedProjectListResponseItem(UUID.fromString(row.uuid()), row.name(), row.version())) + .map(row -> { + final var project = projectMap.get(UUID.fromString(row.uuid())); + final var parent = project != null + ? AffectedProject.buildParentInfo(project.getParent()) + : null; + return new TaggedProjectListResponseItem( + UUID.fromString(row.uuid()), row.name(), row.version(), parent); + }) .toList(); final long totalCount = taggedProjectListRows.isEmpty() ? 0 : taggedProjectListRows.getFirst().totalCount(); return Response.ok(tags).header(TOTAL_COUNT_HEADER, totalCount).build(); @@ -312,12 +328,24 @@ public Response getTaggedCollectionProjects( // Will likely need a migration to cleanup existing tags for this. final List taggedCollectionProjectListRows; + final Map projectMap; try (final var qm = new QueryManager(getAlpineRequest())) { taggedCollectionProjectListRows = qm.getTaggedCollectionProjects(tagName); + final var uuids = taggedCollectionProjectListRows.stream() + .map(row -> UUID.fromString(row.uuid())) + .toList(); + projectMap = qm.getProjectsWithAncestorPaths(uuids); } final List tags = taggedCollectionProjectListRows.stream() - .map(row -> new TaggedCollectionProjectListResponseItem(UUID.fromString(row.uuid()), row.name(), row.version())) + .map(row -> { + final var project = projectMap.get(UUID.fromString(row.uuid())); + final var parent = project != null + ? AffectedProject.buildParentInfo(project.getParent()) + : null; + return new TaggedCollectionProjectListResponseItem( + UUID.fromString(row.uuid()), row.name(), row.version(), parent); + }) .toList(); final long totalCount = taggedCollectionProjectListRows.isEmpty() ? 0 : taggedCollectionProjectListRows.getFirst().totalCount(); return Response.ok(tags).header(TOTAL_COUNT_HEADER, totalCount).build(); diff --git a/src/main/java/org/dependencytrack/resources/v1/vo/AffectedProject.java b/src/main/java/org/dependencytrack/resources/v1/vo/AffectedProject.java index b495167666..7777548e91 100644 --- a/src/main/java/org/dependencytrack/resources/v1/vo/AffectedProject.java +++ b/src/main/java/org/dependencytrack/resources/v1/vo/AffectedProject.java @@ -22,6 +22,8 @@ import java.util.List; import java.util.UUID; +import org.dependencytrack.model.Project; + /** * Describes a project that is affected by a specific vulnerability, including a list of UUIDs of the components * affected by the vulnerability within this project. @@ -42,13 +44,17 @@ public class AffectedProject { private final List affectedComponentUuids; - public AffectedProject(UUID uuid, boolean dependencyGraphAvailable, String name, String version, boolean active, List affectedComponentUuids) { + private final ProjectParentInfo parent; + + public AffectedProject(UUID uuid, boolean dependencyGraphAvailable, String name, String version, boolean active, + List affectedComponentUuids, ProjectParentInfo parent) { this.uuid = uuid; this.dependencyGraphAvailable = dependencyGraphAvailable; this.name = name; this.version = version; this.active = active; this.affectedComponentUuids = affectedComponentUuids == null ? new ArrayList<>() : affectedComponentUuids; + this.parent = parent; } public UUID getUuid() { @@ -58,6 +64,7 @@ public UUID getUuid() { public boolean isDependencyGraphAvailable() { return dependencyGraphAvailable; } + public String getName() { return name; } @@ -73,4 +80,22 @@ public boolean getActive() { public List getAffectedComponentUuids() { return affectedComponentUuids; } + + public ProjectParentInfo getParent() { + return parent; + } + + /** + * Builds a nested ProjectParentInfo from a Project's parent chain (after it has been wired). + */ + public static ProjectParentInfo buildParentInfo(Project project) { + if (project == null) { + return null; + } + return new ProjectParentInfo( + project.getUuid(), + project.getName(), + project.getVersion(), + buildParentInfo(project.getParent())); + } } diff --git a/src/main/java/org/dependencytrack/resources/v1/vo/ProjectParentInfo.java b/src/main/java/org/dependencytrack/resources/v1/vo/ProjectParentInfo.java new file mode 100644 index 0000000000..ccc35e10a2 --- /dev/null +++ b/src/main/java/org/dependencytrack/resources/v1/vo/ProjectParentInfo.java @@ -0,0 +1,28 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.resources.v1.vo; + +import java.util.UUID; + +/** + * Represents a project's parent in a nested structure (parent containing parent containing ...). + * Used for hierarchy display in API responses. + */ +public record ProjectParentInfo(UUID uuid, String name, String version, ProjectParentInfo parent) { +} diff --git a/src/main/java/org/dependencytrack/resources/v1/vo/TaggedCollectionProjectListResponseItem.java b/src/main/java/org/dependencytrack/resources/v1/vo/TaggedCollectionProjectListResponseItem.java index f6f51a734a..29aa21283d 100644 --- a/src/main/java/org/dependencytrack/resources/v1/vo/TaggedCollectionProjectListResponseItem.java +++ b/src/main/java/org/dependencytrack/resources/v1/vo/TaggedCollectionProjectListResponseItem.java @@ -32,5 +32,6 @@ public record TaggedCollectionProjectListResponseItem( @Schema(description = "UUID of the collection project", requiredMode = REQUIRED) UUID uuid, @Schema(description = "Name of the collection project", requiredMode = REQUIRED) String name, - @Schema(description = "Version of the collection project") String version) { + @Schema(description = "Version of the collection project") String version, + @Schema(description = "Parent project (nested chain for hierarchy display)") ProjectParentInfo parent) { } diff --git a/src/main/java/org/dependencytrack/resources/v1/vo/TaggedProjectListResponseItem.java b/src/main/java/org/dependencytrack/resources/v1/vo/TaggedProjectListResponseItem.java index 173ccb5043..9d7e26d5ab 100644 --- a/src/main/java/org/dependencytrack/resources/v1/vo/TaggedProjectListResponseItem.java +++ b/src/main/java/org/dependencytrack/resources/v1/vo/TaggedProjectListResponseItem.java @@ -32,5 +32,6 @@ public record TaggedProjectListResponseItem( @Schema(description = "UUID of the project", requiredMode = REQUIRED) UUID uuid, @Schema(description = "Name of the project", requiredMode = REQUIRED) String name, - @Schema(description = "Version of the project") String version) { + @Schema(description = "Version of the project") String version, + @Schema(description = "Parent project (nested chain for hierarchy display)") ProjectParentInfo parent) { } diff --git a/src/test/java/org/dependencytrack/persistence/ProjectQueryManagerTest.java b/src/test/java/org/dependencytrack/persistence/ProjectQueryManagerTest.java index 05cf9a51bd..66916d4b0d 100644 --- a/src/test/java/org/dependencytrack/persistence/ProjectQueryManagerTest.java +++ b/src/test/java/org/dependencytrack/persistence/ProjectQueryManagerTest.java @@ -132,4 +132,68 @@ public void testCloneProjectMetricUpdate() throws Exception { } } + @Test + void testGetProjectPopulatesNestedParentChain() { + final Project grandparent = qm.createProject("grandparent", null, "1.0", null, null, null, true, false); + final Project parent = new Project(); + parent.setName("parent"); + parent.setVersion("1.0"); + parent.setParent(grandparent); + qm.persist(parent); + final Project project = new Project(); + project.setName("child"); + project.setVersion("1.0"); + project.setParent(parent); + qm.persist(project); + + final Project fetched = qm.getProject(project.getUuid().toString()); + Assertions.assertNotNull(fetched); + + // Nested parent chain is wired + Assertions.assertNotNull(fetched.getParent()); + Assertions.assertEquals("parent", fetched.getParent().getName()); + Assertions.assertNotNull(fetched.getParent().getParent()); + Assertions.assertEquals("grandparent", fetched.getParent().getParent().getName()); + Assertions.assertNull(fetched.getParent().getParent().getParent()); + } + + @Test + void testGetProjectPopulatesDeepParentChain() { + // 4 levels: root -> level1 -> level2 -> leaf + final Project root = qm.createProject("root", null, "1.0", null, null, null, true, false); + final Project level1 = new Project(); + level1.setName("level1"); + level1.setVersion("1.0"); + level1.setParent(root); + qm.persist(level1); + final Project level2 = new Project(); + level2.setName("level2"); + level2.setVersion("1.0"); + level2.setParent(level1); + qm.persist(level2); + final Project leaf = new Project(); + leaf.setName("leaf"); + leaf.setVersion("1.0"); + leaf.setParent(level2); + qm.persist(leaf); + + final Project fetched = qm.getProject(leaf.getUuid().toString()); + Assertions.assertNotNull(fetched); + + // All ancestors must have name/version populated (validates fetch group fix) + Assertions.assertNotNull(fetched.getParent()); + Assertions.assertEquals("level2", fetched.getParent().getName()); + Assertions.assertEquals("1.0", fetched.getParent().getVersion()); + + Assertions.assertNotNull(fetched.getParent().getParent()); + Assertions.assertEquals("level1", fetched.getParent().getParent().getName()); + Assertions.assertEquals("1.0", fetched.getParent().getParent().getVersion()); + + Assertions.assertNotNull(fetched.getParent().getParent().getParent()); + Assertions.assertEquals("root", fetched.getParent().getParent().getParent().getName()); + Assertions.assertEquals("1.0", fetched.getParent().getParent().getParent().getVersion()); + + Assertions.assertNull(fetched.getParent().getParent().getParent().getParent()); + } + } \ No newline at end of file diff --git a/src/test/java/org/dependencytrack/resources/v1/FindingResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/FindingResourceTest.java index 128bdc350e..45e49e7744 100644 --- a/src/test/java/org/dependencytrack/resources/v1/FindingResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/FindingResourceTest.java @@ -27,6 +27,7 @@ import alpine.server.filters.AuthenticationFilter; import jakarta.json.JsonArray; import jakarta.json.JsonObject; +import jakarta.json.JsonValue; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.Response; import org.dependencytrack.JerseyTestExtension; @@ -410,12 +411,74 @@ void getAllFindings() { Assertions.assertEquals(p1_child.getName() ,json.getJsonObject(3).getJsonObject("component").getString("projectName")); Assertions.assertEquals(p1_child.getVersion() ,json.getJsonObject(3).getJsonObject("component").getString("projectVersion")); Assertions.assertEquals(p1_child.getUuid().toString(), json.getJsonObject(3).getJsonObject("component").getString("project")); + // Child project has parent chain for tooltip + Assertions.assertNotNull(json.getJsonObject(3).getJsonObject("component").getJsonObject("parent")); + Assertions.assertEquals(p1.getUuid().toString(), json.getJsonObject(3).getJsonObject("component").getJsonObject("parent").getString("uuid")); + Assertions.assertEquals(p1.getName(), json.getJsonObject(3).getJsonObject("component").getJsonObject("parent").getString("name")); + Assertions.assertEquals(p1.getVersion(), json.getJsonObject(3).getJsonObject("component").getJsonObject("parent").getString("version")); + JsonObject directParent = json.getJsonObject(3).getJsonObject("component").getJsonObject("parent"); + Assertions.assertTrue( + !directParent.containsKey("parent") || directParent.get("parent") == null + || directParent.get("parent").getValueType() == JsonValue.ValueType.NULL, + "Direct parent should have no parent (2-level hierarchy)"); + // Root projects have no parent (key absent or JSON null) + JsonObject rootComponent = json.getJsonObject(0).getJsonObject("component"); + Assertions.assertTrue( + !rootComponent.containsKey("parent") || rootComponent.get("parent") == null + || rootComponent.get("parent").getValueType() == JsonValue.ValueType.NULL, + "Root project component should have no parent"); Assertions.assertEquals(date.getTime() ,json.getJsonObject(4).getJsonObject("vulnerability").getJsonNumber("published").longValue()); Assertions.assertEquals(p2.getName() ,json.getJsonObject(4).getJsonObject("component").getString("projectName")); Assertions.assertEquals(p2.getVersion() ,json.getJsonObject(4).getJsonObject("component").getString("projectVersion")); Assertions.assertEquals(p2.getUuid().toString(), json.getJsonObject(4).getJsonObject("component").getString("project")); } + @Test + void getAllFindingsReturnsFullAncestorChainForThreeLevelProjectHierarchy() { + // Create 3-level hierarchy: Root -> Project A -> API (leaf with vulnerability) + Project root = qm.createProject("Root", null, "1.0", null, null, null, true, false); + Project projectA = qm.createProject("Project A", null, null, null, root, null, true, false); + Project api = qm.createProject("API", null, "1.0", null, projectA, null, true, false); + Component c = createComponent(api, "xml2js", "0.4.23"); + Vulnerability v = createVulnerability("GHSA-776f-qx25-q3cc", Severity.MEDIUM); + v.setPublished(new Date()); + qm.persist(v); + qm.addVulnerability(v, c, AnalyzerIdentity.NONE); + + Response response = jersey.target(V1_FINDING) + .queryParam("sortName", "component.projectName") + .queryParam("sortOrder", "asc") + .request() + .header(X_API_KEY, apiKey) + .get(Response.class); + + Assertions.assertEquals(200, response.getStatus()); + JsonArray json = parseJsonArray(response); + Assertions.assertNotNull(json); + Assertions.assertEquals(1, json.size()); + + // Find the finding for the leaf project (API) + JsonObject component = json.getJsonObject(0).getJsonObject("component"); + Assertions.assertEquals(api.getUuid().toString(), component.getString("project")); + Assertions.assertEquals("API", component.getString("projectName")); + + // Assert full ancestor chain: parent (Project A) -> parent (Root) -> parent null + Assertions.assertNotNull(component.getJsonObject("parent"), "component.parent should be present"); + JsonObject parentA = component.getJsonObject("parent"); + Assertions.assertEquals(projectA.getUuid().toString(), parentA.getString("uuid")); + Assertions.assertEquals("Project A", parentA.getString("name")); + + Assertions.assertNotNull(parentA.getJsonObject("parent"), "parent.parent (Root) should be present for 3-level hierarchy"); + JsonObject parentRoot = parentA.getJsonObject("parent"); + Assertions.assertEquals(root.getUuid().toString(), parentRoot.getString("uuid")); + Assertions.assertEquals("Root", parentRoot.getString("name")); + // Root has no parent: key absent or JSON null + Assertions.assertTrue( + !parentRoot.containsKey("parent") || parentRoot.get("parent") == null + || parentRoot.get("parent").getValueType() == JsonValue.ValueType.NULL, + "Root should have no parent"); + } + @Test void getAllFindingsWithAclEnabled() { Project p1 = qm.createProject("Acme Example", null, "1.0", null, null, null, true, false); diff --git a/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java index 13e900d64a..fc05cf3b56 100644 --- a/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java @@ -226,6 +226,39 @@ void getProjectLookupTest() { Assertions.assertEquals("100", json.getJsonArray("versions").getJsonObject(100).getString("version")); } + @Test + void getProjectLookupReturnsNestedParentChainTest() { + final var parentProject = new Project(); + parentProject.setName("acme-parent"); + parentProject.setVersion("2.0"); + qm.persist(parentProject); + + final var project = new Project(); + project.setName("acme-lookup-child"); + project.setVersion("1.0"); + project.setParent(parentProject); + qm.persist(project); + + Response response = jersey.target(V1_PROJECT + "/lookup") + .queryParam("name", "acme-lookup-child") + .queryParam("version", "1.0") + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + JsonObject json = parseJsonObject(response); + assertThat(json).isNotNull(); + assertThat(json.getString("name")).isEqualTo("acme-lookup-child"); + assertThat(json.getString("version")).isEqualTo("1.0"); + + JsonObject parent = json.getJsonObject("parent"); + assertThat(parent).isNotNull(); + assertThat(parent.getString("name")).isEqualTo("acme-parent"); + assertThat(parent.getString("version")).isEqualTo("2.0"); + assertThat(parent.getString("uuid")).isEqualTo(parentProject.getUuid().toString()); + assertThat(parent.containsKey("parent")).isFalse(); // root, no further parent + } + @Test void getProjectLookupNotFoundTest() { final var project = new Project(); @@ -383,6 +416,107 @@ void getProjectByUuidTest() { """); } + @Test + void getProjectByUuidWithNestedParentChainTest() { + final var grandparentProject = new Project(); + grandparentProject.setName("acme-org"); + grandparentProject.setVersion("1.0"); + qm.persist(grandparentProject); + + final var parentProject = new Project(); + parentProject.setName("acme-app-parent"); + parentProject.setVersion("1.0.0"); + parentProject.setParent(grandparentProject); + qm.persist(parentProject); + + final var project = new Project(); + project.setName("acme-app"); + project.setVersion("1.0.0"); + project.setParent(parentProject); + qm.persist(project); + + Response response = jersey.target(V1_PROJECT + "/" + project.getUuid()) + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + JsonObject json = parseJsonObject(response); + assertThat(json).isNotNull(); + + // Assert nested parent chain: project -> parent -> grandparent -> null + JsonObject parent = json.getJsonObject("parent"); + assertThat(parent).isNotNull(); + assertThat(parent.getString("name")).isEqualTo("acme-app-parent"); + assertThat(parent.getString("version")).isEqualTo("1.0.0"); + assertThat(parent.getString("uuid")).isEqualTo(parentProject.getUuid().toString()); + + JsonObject grandparent = parent.getJsonObject("parent"); + assertThat(grandparent).isNotNull(); + assertThat(grandparent.getString("name")).isEqualTo("acme-org"); + assertThat(grandparent.getString("version")).isEqualTo("1.0"); + assertThat(grandparent.getString("uuid")).isEqualTo(grandparentProject.getUuid().toString()); + + // Root has no parent (key omitted with NON_NULL or null) + assertThat(grandparent.containsKey("parent")).isFalse(); + } + + @Test + void getProjectByUuidWithDeepParentChainTest() { + // 4 levels: root -> org -> product -> app + final var root = new Project(); + root.setName("acme-root"); + root.setVersion("1.0"); + qm.persist(root); + + final var org = new Project(); + org.setName("acme-org"); + org.setVersion("2.0"); + org.setParent(root); + qm.persist(org); + + final var product = new Project(); + product.setName("acme-product"); + product.setVersion("3.0"); + product.setParent(org); + qm.persist(product); + + final var app = new Project(); + app.setName("acme-app"); + app.setVersion("4.0"); + app.setParent(product); + qm.persist(app); + + Response response = jersey.target(V1_PROJECT + "/" + app.getUuid()) + .request() + .header(X_API_KEY, apiKey) + .get(); + assertThat(response.getStatus()).isEqualTo(200); + JsonObject json = parseJsonObject(response); + assertThat(json).isNotNull(); + assertThat(json.getString("name")).isEqualTo("acme-app"); + + // Assert full parent chain with all fields populated (no empty objects) + JsonObject parent1 = json.getJsonObject("parent"); + assertThat(parent1).isNotNull(); + assertThat(parent1.getString("name")).isEqualTo("acme-product"); + assertThat(parent1.getString("version")).isEqualTo("3.0"); + assertThat(parent1.getString("uuid")).isEqualTo(product.getUuid().toString()); + + JsonObject parent2 = parent1.getJsonObject("parent"); + assertThat(parent2).isNotNull(); + assertThat(parent2.getString("name")).isEqualTo("acme-org"); + assertThat(parent2.getString("version")).isEqualTo("2.0"); + assertThat(parent2.getString("uuid")).isEqualTo(org.getUuid().toString()); + + JsonObject parent3 = parent2.getJsonObject("parent"); + assertThat(parent3).isNotNull(); + assertThat(parent3.getString("name")).isEqualTo("acme-root"); + assertThat(parent3.getString("version")).isEqualTo("1.0"); + assertThat(parent3.getString("uuid")).isEqualTo(root.getUuid().toString()); + + assertThat(parent3.containsKey("parent")).isFalse(); + } + @Test void getProjectByUuidNotPermittedTest() { enablePortfolioAccessControl(); @@ -1857,6 +1991,39 @@ void getChildrenProjectsTest() { ); } + @Test + void getChildrenProjectsWithDeepParentChainTest() { + // root -> parent -> child; fetch children of parent (returns child with 2-level parent chain) + final var root = qm.createProject("root", null, "1.0", null, null, null, true, false); + final var parent = qm.createProject("parent", null, "2.0", null, root, null, true, false); + final var child = qm.createProject("child", null, "3.0", null, parent, null, true, false); + + Response response = jersey.target(V1_PROJECT + "/" + parent.getUuid() + "/children") + .request() + .header(X_API_KEY, apiKey) + .get(Response.class); + assertThat(response.getStatus()).isEqualTo(200); + JsonArray json = parseJsonArray(response); + assertThat(json.size()).isEqualTo(1); + JsonObject childJson = json.getJsonObject(0); + assertThat(childJson.getString("name")).isEqualTo("child"); + assertThat(childJson.getString("uuid")).isEqualTo(child.getUuid().toString()); + + // Parent chain must be fully populated (no empty {} at root) + JsonObject childParent = childJson.getJsonObject("parent"); + assertThat(childParent).isNotNull(); + assertThat(childParent.getString("name")).isEqualTo("parent"); + assertThat(childParent.getString("version")).isEqualTo("2.0"); + assertThat(childParent.getString("uuid")).isEqualTo(parent.getUuid().toString()); + + JsonObject grandparent = childParent.getJsonObject("parent"); + assertThat(grandparent).isNotNull(); + assertThat(grandparent.getString("name")).isEqualTo("root"); + assertThat(grandparent.getString("version")).isEqualTo("1.0"); + assertThat(grandparent.getString("uuid")).isEqualTo(root.getUuid().toString()); + assertThat(grandparent.containsKey("parent")).isFalse(); + } + @Test void updateChildAsParentOfChild() { Project parent = qm.createProject("ABC",null, "1.0", null, null, null, true, false); @@ -2451,6 +2618,38 @@ void getLatestProjectTest() { Assertions.assertEquals("1.0.2", json.getString("version")); } + @Test + void getLatestProjectReturnsNestedParentChainTest() { + final var parentProject = new Project(); + parentProject.setName("acme-latest-parent"); + parentProject.setVersion("1.0"); + qm.persist(parentProject); + + qm.createProject("Acme Latest", null, "1.0.0", null, null, null, true, false); + final var latestProject = new Project(); + latestProject.setName("Acme Latest"); + latestProject.setVersion("1.0.2"); + latestProject.setParent(parentProject); + latestProject.setIsLatest(true); + qm.persist(latestProject); + + Response response = jersey.target(V1_PROJECT_LATEST + "Acme Latest") + .request() + .header(X_API_KEY, apiKey) + .get(Response.class); + assertThat(response.getStatus()).isEqualTo(200); + JsonObject json = parseJsonObject(response); + assertThat(json).isNotNull(); + assertThat(json.getString("name")).isEqualTo("Acme Latest"); + assertThat(json.getString("version")).isEqualTo("1.0.2"); + + JsonObject parent = json.getJsonObject("parent"); + assertThat(parent).isNotNull(); + assertThat(parent.getString("name")).isEqualTo("acme-latest-parent"); + assertThat(parent.getString("version")).isEqualTo("1.0"); + assertThat(parent.getString("uuid")).isEqualTo(parentProject.getUuid().toString()); + } + @Test void getLatestProjectWithAclEnabledTest() { enablePortfolioAccessControl();