From f8563f9a5838bd98ffbf7c980826c198803b7832 Mon Sep 17 00:00:00 2001 From: Sebastien Delcoigne Date: Thu, 29 Feb 2024 22:25:26 +1100 Subject: [PATCH] Fixes #3509 Allows definition of pURL in component or projects either as string or object to conform to swagger api defintion Signed-off-by: Sebastien Delcoigne --- .../org/dependencytrack/model/Component.java | 4 +- .../org/dependencytrack/model/Project.java | 3 +- .../CustomPackageURLDeserializer.java | 96 +++++++++++++++++++ .../resources/v1/ComponentResourceTest.java | 67 +++++++++++++ .../resources/v1/ProjectResourceTest.java | 41 +++++++- 5 files changed, 205 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/dependencytrack/resources/v1/serializers/CustomPackageURLDeserializer.java diff --git a/src/main/java/org/dependencytrack/model/Component.java b/src/main/java/org/dependencytrack/model/Component.java index 6eec73ad7d..c1a51b14f4 100644 --- a/src/main/java/org/dependencytrack/model/Component.java +++ b/src/main/java/org/dependencytrack/model/Component.java @@ -30,6 +30,7 @@ import org.apache.commons.lang3.StringUtils; import org.dependencytrack.model.validation.ValidSpdxExpression; import org.dependencytrack.persistence.converter.OrganizationalEntityJsonConverter; +import org.dependencytrack.resources.v1.serializers.CustomPackageURLDeserializer; import org.dependencytrack.resources.v1.serializers.CustomPackageURLSerializer; import javax.jdo.annotations.Column; @@ -249,7 +250,7 @@ public enum FetchGroup { @Index(name = "COMPONENT_PURL_IDX") @Size(max = 255) @com.github.packageurl.validator.PackageURL - @JsonDeserialize(using = TrimmedStringDeserializer.class) + @JsonDeserialize(using = CustomPackageURLDeserializer.class) private String purl; @Persistent(defaultFetchGroup = "true") @@ -555,6 +556,7 @@ public void setCpe(String cpe) { } @JsonSerialize(using = CustomPackageURLSerializer.class) + @JsonDeserialize(using = CustomPackageURLDeserializer.class) public PackageURL getPurl() { if (purl == null) { return null; diff --git a/src/main/java/org/dependencytrack/model/Project.java b/src/main/java/org/dependencytrack/model/Project.java index 81a36fb203..7467109f82 100644 --- a/src/main/java/org/dependencytrack/model/Project.java +++ b/src/main/java/org/dependencytrack/model/Project.java @@ -33,6 +33,7 @@ import com.github.packageurl.PackageURL; import io.swagger.annotations.ApiModelProperty; import org.dependencytrack.persistence.converter.OrganizationalEntityJsonConverter; +import org.dependencytrack.resources.v1.serializers.CustomPackageURLDeserializer; import org.dependencytrack.resources.v1.serializers.CustomPackageURLSerializer; import javax.jdo.annotations.Column; @@ -197,7 +198,7 @@ public enum FetchGroup { @Index(name = "PROJECT_PURL_IDX") @Size(max = 255) @com.github.packageurl.validator.PackageURL - @JsonDeserialize(using = TrimmedStringDeserializer.class) + @JsonDeserialize(using = CustomPackageURLDeserializer.class) private String purl; @Persistent diff --git a/src/main/java/org/dependencytrack/resources/v1/serializers/CustomPackageURLDeserializer.java b/src/main/java/org/dependencytrack/resources/v1/serializers/CustomPackageURLDeserializer.java new file mode 100644 index 0000000000..a7d7fbb1f9 --- /dev/null +++ b/src/main/java/org/dependencytrack/resources/v1/serializers/CustomPackageURLDeserializer.java @@ -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) Steve Springett. All Rights Reserved. + */ +package org.dependencytrack.resources.v1.serializers; + +import alpine.common.logging.Logger; +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.github.packageurl.MalformedPackageURLException; +import com.github.packageurl.PackageURLBuilder; + +import java.io.IOException; + +/** + * This class deserializes a PackageURL from either the canonicalized form + * or the object form. + * + * @author Sebastien Delcoigne + * @since 4.11.0 + */ +public class CustomPackageURLDeserializer extends JsonDeserializer { + + private static final Logger LOGGER = Logger.getLogger(CustomPackageURLDeserializer.class); + + @Override + public String deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JacksonException { + try { + if (jsonParser.getCurrentToken() == JsonToken.START_OBJECT) { + PackageURLBuilder builder = PackageURLBuilder.aPackageURL(); + while (jsonParser.nextToken() != JsonToken.END_OBJECT) { + String name = jsonParser.getCurrentName(); + switch (name) { + case "type" -> { + jsonParser.nextToken(); + builder.withType(jsonParser.getText()); + } + case "namespace" -> { + jsonParser.nextToken(); + builder.withNamespace(jsonParser.getText()); + } + case "name" -> { + jsonParser.nextToken(); + builder.withName(jsonParser.getText()); + } + case "version" -> { + jsonParser.nextToken(); + builder.withVersion(jsonParser.getText()); + } + case "subpath" -> { + jsonParser.nextToken(); + builder.withSubpath(jsonParser.getText()); + } + case "qualifiers" -> { + extractQualifiers(jsonParser, builder); + } + } + } + return builder.build().canonicalize(); + } else if (jsonParser.getCurrentToken() == JsonToken.VALUE_STRING) { + return jsonParser.getValueAsString(); + } + } catch (MalformedPackageURLException e) { + LOGGER.warn("Malformed pURL encountered", e); + } + return null; + } + + private void extractQualifiers(JsonParser jsonParser, PackageURLBuilder builder) throws IOException { + if (jsonParser.getCurrentToken() == JsonToken.START_OBJECT) { + while (jsonParser.nextToken() != JsonToken.END_OBJECT) { + String key = jsonParser.getCurrentName(); + jsonParser.nextToken(); + String value = jsonParser.getText(); + builder.withQualifier(key, value); + } + } + } +} diff --git a/src/test/java/org/dependencytrack/resources/v1/ComponentResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/ComponentResourceTest.java index 102d7be58b..32e3670136 100644 --- a/src/test/java/org/dependencytrack/resources/v1/ComponentResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/ComponentResourceTest.java @@ -477,6 +477,7 @@ public void createComponentTest() { component.setProject(project); component.setName("My Component"); component.setVersion("1.0"); + component.setPurl("pkg:npm/space/acme-example@1.0"); Response response = target(V1_COMPONENT + "/project/" + project.getUuid().toString()).request() .header(X_API_KEY, apiKey) .put(Entity.entity(component, MediaType.APPLICATION_JSON)); @@ -485,6 +486,35 @@ public void createComponentTest() { Assert.assertNotNull(json); Assert.assertEquals("My Component", json.getString("name")); Assert.assertEquals("1.0", json.getString("version")); + Assert.assertEquals("pkg:npm/space/acme-example@1.0", json.getString("purl")); + Assert.assertTrue(UuidUtil.isValidUUID(json.getString("uuid"))); + } + + @Test + public void createComponentWithPurlPassedAsObjectTest() { + Project project = qm.createProject("Acme Application", null, null, null, null, null, true, false); + String component = """ + { + "name": "My Component", + "version": "1.0", + "purl": { + "scheme": "pkg", + "type": "npm", + "namespace": "space", + "name": "my-component", + "version": "1.0" + } + } + """; + Response response = target(V1_COMPONENT + "/project/" + project.getUuid().toString()).request() + .header(X_API_KEY, apiKey) + .put(Entity.json(component)); + Assert.assertEquals(201, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertEquals("My Component", json.getString("name")); + Assert.assertEquals("1.0", json.getString("version")); + Assert.assertEquals("pkg:npm/space/my-component@1.0", json.getString("purl")); Assert.assertTrue(UuidUtil.isValidUUID(json.getString("uuid"))); } @@ -531,6 +561,7 @@ public void updateComponentTest() { component.setVersion("1.0"); component = qm.createComponent(component, false); component.setDescription("Test component"); + component.setPurl("pkg:npm/space/acme-example@1.0"); Response response = target(V1_COMPONENT).request() .header(X_API_KEY, apiKey) .post(Entity.entity(component, MediaType.APPLICATION_JSON)); @@ -540,6 +571,42 @@ public void updateComponentTest() { Assert.assertEquals("My Component", json.getString("name")); Assert.assertEquals("1.0", json.getString("version")); Assert.assertEquals("Test component", json.getString("description")); + Assert.assertEquals("pkg:npm/space/acme-example@1.0", json.getString("purl")); + } + + @Test + public void updateComponentWithPurlPassedAsObjectTest() { + Project project = qm.createProject("Acme Application", null, null, null, null, null, true, false); + Component component = new Component(); + component.setProject(project); + component.setName("My Component"); + component.setVersion("1.0"); + qm.createComponent(component, false); + String componentUpdate = """ + { + "uuid": "%s", + "name": "My Component", + "version": "1.0", + "description": "Test component", + "purl": { + "scheme": "pkg", + "type": "npm", + "namespace": "space", + "name": "my-component", + "version": "1.0" + } + } + """.formatted(component.getUuid()); + Response response = target(V1_COMPONENT).request() + .header(X_API_KEY, apiKey) + .post(Entity.entity(componentUpdate, MediaType.APPLICATION_JSON)); + Assert.assertEquals(200, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertEquals("My Component", json.getString("name")); + Assert.assertEquals("1.0", json.getString("version")); + Assert.assertEquals("Test component", json.getString("description")); + Assert.assertEquals("pkg:npm/space/my-component@1.0", json.getString("purl")); } @Test diff --git a/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java index eaae9e743b..067ac06d27 100644 --- a/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/ProjectResourceTest.java @@ -343,10 +343,11 @@ public void createProjectTest(){ project.setName("Acme Example"); project.setVersion("1.0"); project.setDescription("Test project"); + project.setPurl("pkg:npm/space/acme-example@1.0"); Response response = target(V1_PROJECT) .request() .header(X_API_KEY, apiKey) - .put(Entity.entity(project, MediaType.APPLICATION_JSON)); + .put(Entity.json(project)); Assert.assertEquals(201, response.getStatus(), 0); JsonObject json = parseJsonObject(response); Assert.assertNotNull(json); @@ -354,9 +355,41 @@ public void createProjectTest(){ Assert.assertEquals("1.0", json.getString("version")); Assert.assertEquals("Test project", json.getString("description")); Assert.assertTrue(json.getBoolean("active")); + Assert.assertEquals("pkg:npm/space/acme-example@1.0", json.getString("purl")); Assert.assertTrue(UuidUtil.isValidUUID(json.getString("uuid"))); } + @Test + public void createProjectWithPurlObjectTest(){ + String project = """ + { + "name" : "Test", + "version": "1.0", + "description": "Test project", + "purl": { + "scheme": "pkg", + "type": "npm", + "namespace": "space", + "name": "test", + "version": "1.0" + } + } + """; + Response response = target(V1_PROJECT) + .request() + .header(X_API_KEY, apiKey) + .put(Entity.json(project)); + Assert.assertEquals(201, response.getStatus(), 0); + JsonObject json = parseJsonObject(response); + Assert.assertNotNull(json); + Assert.assertEquals("Test", json.getString("name")); + Assert.assertEquals("1.0", json.getString("version")); + Assert.assertEquals("Test project", json.getString("description")); + Assert.assertTrue(json.getBoolean("active")); + Assert.assertTrue(UuidUtil.isValidUUID(json.getString("uuid"))); + Assert.assertEquals("pkg:npm/space/test@1.0", json.getString("purl")); + } + @Test public void createProjectDuplicateTest() { Project project = new Project(); @@ -365,12 +398,12 @@ public void createProjectDuplicateTest() { Response response = target(V1_PROJECT) .request() .header(X_API_KEY, apiKey) - .put(Entity.entity(project, MediaType.APPLICATION_JSON)); + .put(Entity.json(project)); Assert.assertEquals(201, response.getStatus(), 0); response = target(V1_PROJECT) .request() .header(X_API_KEY, apiKey) - .put(Entity.entity(project, MediaType.APPLICATION_JSON)); + .put(Entity.json(project)); Assert.assertEquals(409, response.getStatus(), 0); String body = getPlainTextBody(response); Assert.assertEquals("A project with the specified name already exists.", body); @@ -401,7 +434,7 @@ public void createProjectEmptyTest() { Response response = target(V1_PROJECT) .request() .header(X_API_KEY, apiKey) - .put(Entity.entity(project, MediaType.APPLICATION_JSON)); + .put(Entity.json(project)); Assert.assertEquals(400, response.getStatus(), 0); }