Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/main/java/org/dependencytrack/model/Project.java
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Project> componentProjects = result.getList(Component.class).stream()
.map(Component::getProject)
.filter(Objects::nonNull)
.distinct()
.toList();
populateAncestorPaths(componentProjects);
return result;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,23 @@
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;
import org.dependencytrack.model.VulnerabilityAlias;

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 {

Expand Down Expand Up @@ -157,10 +162,37 @@ public PaginatedResult getAllFindings(final Map<String, String> 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<Finding> findings) {
final Set<UUID> 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<UUID, Project> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -1032,13 +1040,13 @@ public void deleteProjectsByUUIDs(Collection<UUID> 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)
);
Expand All @@ -1060,13 +1068,13 @@ public void deleteProjectsByUUIDs(Collection<UUID> 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(?)
);
Expand Down Expand Up @@ -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)
);
Expand Down Expand Up @@ -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)
);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1695,7 +1705,9 @@ public PaginatedResult getChildrenProjects(final Tag tag, final UUID uuid, final
final Map<String, Object> 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.
Expand Down Expand Up @@ -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<UUID, Project> getProjectsWithAncestorPaths(Collection<UUID> uuids) {
if (uuids == null || uuids.isEmpty()) {
return Map.of();
}
final List<Project> 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){
Expand Down
113 changes: 113 additions & 0 deletions src/main/java/org/dependencytrack/persistence/QueryManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -445,6 +447,10 @@ public boolean doesProjectExist(final String name, final String version) {
return getProjectQueryManager().doesProjectExist(name, version);
}

public Map<UUID, Project> getProjectsWithAncestorPaths(final Collection<UUID> uuids) {
return getProjectQueryManager().getProjectsWithAncestorPaths(uuids);
}

public Tag getTagByName(final String name) {
return getTagQueryManager().getTagByName(name);
}
Expand Down Expand Up @@ -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<Project> projects) {
if (projects == null || projects.isEmpty()) {
return;
}

// Build a map of all projects by UUID for quick lookup
final Map<UUID, Project> 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<UUID> 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<Project> 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<Project> 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<Project> 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<Project> fetchProjectsByUuids(Set<UUID> uuids) {
if (uuids == null || uuids.isEmpty()) {
return List.of();
}
final Query<Project> 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<Project> buildParentChainFromMap(Project project, Map<UUID, Project> projectMap) {
final List<Project> 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;
}

}
Loading
Loading