diff --git a/src/main/java/org/dependencytrack/persistence/ComponentQueryManager.java b/src/main/java/org/dependencytrack/persistence/ComponentQueryManager.java index a163599617..5ed0c17f41 100644 --- a/src/main/java/org/dependencytrack/persistence/ComponentQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/ComponentQueryManager.java @@ -379,6 +379,7 @@ public Component cloneComponent(Component sourceComponent, Project destinationPr component.setResolvedLicense(sourceComponent.getResolvedLicense()); component.setAuthors(sourceComponent.getAuthors()); component.setSupplier(sourceComponent.getSupplier()); + component.setDirectDependencies(sourceComponent.getDirectDependencies()); // TODO Add support for parent component and children components component.setProject(destinationProject); return createComponent(component, commitIndex); diff --git a/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java b/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java index 91ac6fba82..89a2aa6285 100644 --- a/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/ProjectQueryManager.java @@ -28,6 +28,9 @@ import alpine.notification.NotificationLevel; import alpine.persistence.PaginatedResult; import alpine.resources.AlpineRequest; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.json.JsonMapper; import com.github.packageurl.PackageURL; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; @@ -58,12 +61,15 @@ import javax.jdo.Query; import javax.jdo.metadata.MemberMetadata; import javax.jdo.metadata.TypeMetadata; +import java.io.IOException; import java.security.Principal; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; final class ProjectQueryManager extends QueryManager implements IQueryManager { @@ -512,6 +518,8 @@ public Project clone( final boolean includeACL, final boolean includePolicyViolations ) { + final var jsonMapper = new JsonMapper(); + final Project clonedProject = callInTransaction(() -> { final Project source = getObjectByUuid(Project.class, from, Project.FetchGroup.ALL.name()); if (source == null) { @@ -539,7 +547,7 @@ public Project clone( project.setCpe(source.getCpe()); project.setPurl(source.getPurl()); project.setSwidTagId(source.getSwidTagId()); - if (includeComponents && includeServices) { + if (source.getDirectDependencies() != null && includeComponents && includeServices) { project.setDirectDependencies(source.getDirectDependencies()); } project.setParent(source.getParent()); @@ -573,7 +581,17 @@ public Project clone( } } - final Map clonedComponents = new HashMap<>(); + final var projectDirectDepsSourceComponentUuids = new HashSet(); + if (project.getDirectDependencies() != null) { + projectDirectDepsSourceComponentUuids.addAll( + parseDirectDependenciesUuids(jsonMapper, project.getDirectDependencies())); + } + + final var clonedComponentById = new HashMap(); + final var clonedComponentBySourceComponentId = new HashMap(); + final var directDepsSourceComponentUuidsByClonedComponentId = new HashMap>(); + final var clonedComponentUuidBySourceComponentUuid = new HashMap(); + if (includeComponents) { final List sourceComponents = getAllComponents(source); if (sourceComponents != null) { @@ -584,11 +602,44 @@ public Project clone( final FindingAttribution sourceAttribution = this.getFindingAttribution(vuln, sourceComponent); this.addVulnerability(vuln, clonedComponent, sourceAttribution.getAnalyzerIdentity(), sourceAttribution.getAlternateIdentifier(), sourceAttribution.getReferenceUrl(), sourceAttribution.getAttributedOn()); } - clonedComponents.put(sourceComponent.getId(), clonedComponent); + + clonedComponentById.put(clonedComponent.getId(), clonedComponent); + clonedComponentBySourceComponentId.put(sourceComponent.getId(), clonedComponent); + clonedComponentUuidBySourceComponentUuid.put(sourceComponent.getUuid(), clonedComponent.getUuid()); + + if (clonedComponent.getDirectDependencies() != null) { + final Set directDepsUuids = parseDirectDependenciesUuids(jsonMapper, clonedComponent.getDirectDependencies()); + if (!directDepsUuids.isEmpty()) { + directDepsSourceComponentUuidsByClonedComponentId.put(clonedComponent.getId(), directDepsUuids); + } + } } } } + if (!projectDirectDepsSourceComponentUuids.isEmpty()) { + String directDependencies = project.getDirectDependencies(); + for (final UUID sourceComponentUuid : projectDirectDepsSourceComponentUuids) { + final UUID clonedComponentUuid = clonedComponentUuidBySourceComponentUuid.get(sourceComponentUuid); + directDependencies = directDependencies.replace(sourceComponentUuid.toString(), clonedComponentUuid.toString()); + } + + project.setDirectDependencies(directDependencies); + } + + for (final long componentId : directDepsSourceComponentUuidsByClonedComponentId.keySet()) { + final Component component = clonedComponentById.get(componentId); + final Set sourceComponentUuids = directDepsSourceComponentUuidsByClonedComponentId.get(componentId); + + String directDependencies = component.getDirectDependencies(); + for (final UUID sourceComponentUuid : sourceComponentUuids) { + final UUID clonedComponentUuid = clonedComponentUuidBySourceComponentUuid.get(sourceComponentUuid); + directDependencies = directDependencies.replace(sourceComponentUuid.toString(), clonedComponentUuid.toString()); + } + + component.setDirectDependencies(directDependencies); + } + if (includeServices) { final List sourceServices = getAllServiceComponents(source); if (sourceServices != null) { @@ -604,7 +655,7 @@ public Project clone( for (final Analysis sourceAnalysis : analyses) { Analysis analysis = new Analysis(); analysis.setAnalysisState(sourceAnalysis.getAnalysisState()); - final Component clonedComponent = clonedComponents.get(sourceAnalysis.getComponent().getId()); + final Component clonedComponent = clonedComponentBySourceComponentId.get(sourceAnalysis.getComponent().getId()); if (clonedComponent == null) { break; } @@ -642,7 +693,7 @@ public Project clone( final List sourcePolicyViolations = getAllPolicyViolations(source); if (sourcePolicyViolations != null) { for (final PolicyViolation policyViolation : sourcePolicyViolations) { - final Component destinationComponent = clonedComponents.get(policyViolation.getComponent().getId()); + final Component destinationComponent = clonedComponentBySourceComponentId.get(policyViolation.getComponent().getId()); final PolicyViolation clonedPolicyViolation = clonePolicyViolation(policyViolation, destinationComponent); persist(clonedPolicyViolation); } @@ -657,6 +708,30 @@ public Project clone( return clonedProject; } + private static Set parseDirectDependenciesUuids( + final JsonMapper jsonMapper, + final String directDependencies) throws IOException { + final var uuids = new HashSet(); + try (final JsonParser jsonParser = jsonMapper.createParser(directDependencies)) { + JsonToken currentToken = jsonParser.nextToken(); + if (currentToken != JsonToken.START_ARRAY) { + throw new IllegalArgumentException(""" + Expected directDependencies to be a JSON array, \ + but encountered token: %s""".formatted(currentToken)); + } + + while (jsonParser.nextToken() != null) { + if (jsonParser.currentToken() == JsonToken.FIELD_NAME + && "uuid".equals(jsonParser.currentName()) + && jsonParser.nextToken() == JsonToken.VALUE_STRING) { + uuids.add(UUID.fromString(jsonParser.getValueAsString())); + } + } + } + + return uuids; + } + /** * Deletes a Project and all objects dependant on the project. * @param project the Project to delete diff --git a/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java index 76b3fe5ac7..02566180e0 100644 --- a/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java @@ -22,19 +22,11 @@ import alpine.event.framework.EventService; import alpine.model.IConfigProperty.PropertyType; import alpine.model.ManagedUser; -import alpine.model.Team; import alpine.model.Permission; +import alpine.model.Team; import alpine.server.auth.JsonWebToken; import alpine.server.filters.ApiFilter; import alpine.server.filters.AuthenticationFilter; -import jakarta.json.Json; -import jakarta.json.JsonArray; -import jakarta.json.JsonObject; -import jakarta.json.JsonObjectBuilder; -import jakarta.ws.rs.HttpMethod; -import jakarta.ws.rs.client.Entity; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; import org.cyclonedx.model.ExternalReference.Type; import org.dependencytrack.JerseyTestRule; import org.dependencytrack.ResourceTest; @@ -44,6 +36,7 @@ import org.dependencytrack.model.AnalysisResponse; import org.dependencytrack.model.AnalysisState; import org.dependencytrack.model.Component; +import org.dependencytrack.model.ComponentIdentity; import org.dependencytrack.model.ConfigPropertyConstants; import org.dependencytrack.model.ExternalReference; import org.dependencytrack.model.OrganizationalContact; @@ -60,11 +53,20 @@ import org.glassfish.jersey.client.HttpUrlConnectorProvider; import org.glassfish.jersey.server.ResourceConfig; import org.hamcrest.CoreMatchers; +import org.json.JSONArray; import org.junit.After; import org.junit.Assert; import org.junit.ClassRule; import org.junit.Test; +import jakarta.json.Json; +import jakarta.json.JsonArray; +import jakarta.json.JsonObject; +import jakarta.json.JsonObjectBuilder; +import jakarta.ws.rs.HttpMethod; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import java.time.Duration; import java.util.ArrayList; import java.util.HashMap; @@ -78,6 +80,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; public class ProjectResourceTest extends ResourceTest { private ManagedUser testUser; @@ -1107,12 +1110,18 @@ public void cloneProjectTest() { final var componentSupplier = new OrganizationalEntity(); componentSupplier.setName("componentSupplier"); - final var component = new Component(); - component.setProject(project); - component.setName("acme-lib"); - component.setVersion("2.0.0"); - component.setSupplier(componentSupplier); - qm.persist(component); + final var componentA = new Component(); + componentA.setProject(project); + componentA.setName("acme-lib-a"); + componentA.setVersion("2.0.0"); + componentA.setSupplier(componentSupplier); + qm.persist(componentA); + + final var componentB = new Component(); + componentB.setProject(project); + componentB.setName("acme-lib-b"); + componentB.setVersion("2.1.0"); + qm.persist(componentB); final var service = new ServiceComponent(); service.setProject(project); @@ -1120,19 +1129,22 @@ public void cloneProjectTest() { service.setVersion("3.0.0"); qm.persist(service); + project.setDirectDependencies(new JSONArray().put(new ComponentIdentity(componentA).toJSON()).toString()); + componentA.setDirectDependencies(new JSONArray().put(new ComponentIdentity(componentB).toJSON()).toString()); + final var vuln = new Vulnerability(); vuln.setVulnId("INT-123"); vuln.setSource(Vulnerability.Source.INTERNAL); qm.persist(vuln); - qm.addVulnerability(vuln, component, AnalyzerIdentity.INTERNAL_ANALYZER); - final Analysis analysis = qm.makeAnalysis(component, vuln, AnalysisState.NOT_AFFECTED, + qm.addVulnerability(vuln, componentA, AnalyzerIdentity.INTERNAL_ANALYZER); + final Analysis analysis = qm.makeAnalysis(componentA, vuln, AnalysisState.NOT_AFFECTED, AnalysisJustification.REQUIRES_ENVIRONMENT, AnalysisResponse.WILL_NOT_FIX, "details", false); qm.makeAnalysisComment(analysis, "comment", "commenter"); final Response response = jersey.target("%s/clone".formatted(V1_PROJECT)).request() .header(X_API_KEY, apiKey) - .put(Entity.json(""" + .put(Entity.json(/* language=JSON */ """ { "project": "%s", "version": "1.1.0", @@ -1163,6 +1175,18 @@ public void cloneProjectTest() { assertThat(clonedProject.getManufacturer()).isNotNull(); assertThat(clonedProject.getManufacturer().getName()).isEqualTo("projectManufacturer"); assertThat(clonedProject.getAccessTeams()).containsOnly(team); + assertThatJson(clonedProject.getDirectDependencies()) + .withMatcher("notSourceComponentUuid", not(equalTo(componentA.getUuid().toString()))) + .isEqualTo(/* language=JSON */ """ + [ + { + "objectType": "COMPONENT", + "uuid": "${json-unit.matches:notSourceComponentUuid}", + "name": "acme-lib-a", + "version": "2.0.0" + } + ] + """); final List clonedProperties = qm.getProjectProperties(clonedProject); assertThat(clonedProperties).satisfiesExactly(clonedProperty -> { @@ -1184,24 +1208,42 @@ public void cloneProjectTest() { assertThat(clonedMetadata.getSupplier()) .satisfies(entity -> assertThat(entity.getName()).isEqualTo("metadataSupplier")); - assertThat(qm.getAllComponents(clonedProject)).satisfiesExactly(clonedComponent -> { - assertThat(clonedComponent.getUuid()).isNotEqualTo(component.getUuid()); - assertThat(clonedComponent.getName()).isEqualTo("acme-lib"); - assertThat(clonedComponent.getVersion()).isEqualTo("2.0.0"); - assertThat(clonedComponent.getSupplier()).isNotNull(); - assertThat(clonedComponent.getSupplier().getName()).isEqualTo("componentSupplier"); - - assertThat(qm.getAllVulnerabilities(clonedComponent)).containsOnly(vuln); - - assertThat(qm.getAnalysis(clonedComponent, vuln)).satisfies(clonedAnalysis -> { - assertThat(clonedAnalysis.getId()).isNotEqualTo(analysis.getId()); - assertThat(clonedAnalysis.getAnalysisState()).isEqualTo(AnalysisState.NOT_AFFECTED); - assertThat(clonedAnalysis.getAnalysisJustification()).isEqualTo(AnalysisJustification.REQUIRES_ENVIRONMENT); - assertThat(clonedAnalysis.getAnalysisResponse()).isEqualTo(AnalysisResponse.WILL_NOT_FIX); - assertThat(clonedAnalysis.getAnalysisDetails()).isEqualTo("details"); - assertThat(clonedAnalysis.isSuppressed()).isFalse(); - }); - }); + assertThat(qm.getAllComponents(clonedProject)).satisfiesExactlyInAnyOrder( + clonedComponent -> { + assertThat(clonedComponent.getUuid()).isNotEqualTo(componentA.getUuid()); + assertThat(clonedComponent.getName()).isEqualTo("acme-lib-a"); + assertThat(clonedComponent.getVersion()).isEqualTo("2.0.0"); + assertThat(clonedComponent.getSupplier()).isNotNull(); + assertThat(clonedComponent.getSupplier().getName()).isEqualTo("componentSupplier"); + assertThatJson(clonedComponent.getDirectDependencies()) + .withMatcher("notSourceComponentUuid", not(equalTo(componentB.getUuid().toString()))) + .isEqualTo(/* language=JSON */ """ + [ + { + "objectType": "COMPONENT", + "uuid": "${json-unit.matches:notSourceComponentUuid}", + "name": "acme-lib-b", + "version": "2.1.0" + } + ] + """); + + assertThat(qm.getAllVulnerabilities(clonedComponent)).containsOnly(vuln); + + assertThat(qm.getAnalysis(clonedComponent, vuln)).satisfies(clonedAnalysis -> { + assertThat(clonedAnalysis.getId()).isNotEqualTo(analysis.getId()); + assertThat(clonedAnalysis.getAnalysisState()).isEqualTo(AnalysisState.NOT_AFFECTED); + assertThat(clonedAnalysis.getAnalysisJustification()).isEqualTo(AnalysisJustification.REQUIRES_ENVIRONMENT); + assertThat(clonedAnalysis.getAnalysisResponse()).isEqualTo(AnalysisResponse.WILL_NOT_FIX); + assertThat(clonedAnalysis.getAnalysisDetails()).isEqualTo("details"); + assertThat(clonedAnalysis.isSuppressed()).isFalse(); + }); + }, + clonedComponent -> { + assertThat(clonedComponent.getUuid()).isNotEqualTo(componentA.getUuid()); + assertThat(clonedComponent.getName()).isEqualTo("acme-lib-b"); + assertThat(clonedComponent.getVersion()).isEqualTo("2.1.0"); + }); assertThat(qm.getAllServiceComponents(clonedProject)).satisfiesExactly(clonedService -> { assertThat(clonedService.getUuid()).isNotEqualTo(service.getUuid());