From 831296813f32f1329e9e6cb2bfe79b4030252fd2 Mon Sep 17 00:00:00 2001 From: Eric Klatzer Date: Sun, 27 Oct 2024 16:51:56 +0100 Subject: [PATCH] Extend CycloneDX metadata by custom properties Signed-off-by: Eric Klatzer --- .../model/MetadataProperty.java | 71 ++++++++ .../model/ProjectMetadata.java | 27 ++- .../parser/cyclonedx/util/ModelConverter.java | 48 ++++++ .../MetadataPropertyJsonConverter.java | 45 +++++ .../resources/v1/BomResource.java | 1 + .../resources/v1/BomResourceTest.java | 160 ++++++++++-------- .../tasks/BomUploadProcessingTaskTest.java | 6 + src/test/resources/unit/bom-1.xml | 10 ++ 8 files changed, 288 insertions(+), 80 deletions(-) create mode 100644 src/main/java/org/dependencytrack/model/MetadataProperty.java create mode 100644 src/main/java/org/dependencytrack/persistence/converter/MetadataPropertyJsonConverter.java diff --git a/src/main/java/org/dependencytrack/model/MetadataProperty.java b/src/main/java/org/dependencytrack/model/MetadataProperty.java new file mode 100644 index 000000000..f9815e8e7 --- /dev/null +++ b/src/main/java/org/dependencytrack/model/MetadataProperty.java @@ -0,0 +1,71 @@ +/* + * 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.model; + +import java.io.Serializable; +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +import alpine.server.json.TrimmedStringDeserializer; + +/** + * Model class for tracking metadata properties. + * + * @author Eric Klatzer + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class MetadataProperty implements Serializable { + + @JsonDeserialize(using = TrimmedStringDeserializer.class) + private String name; + + @JsonDeserialize(using = TrimmedStringDeserializer.class) + private String value; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final MetadataProperty that = (MetadataProperty) o; + return Objects.equals(name, that.name) && Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(name, value); + } +} diff --git a/src/main/java/org/dependencytrack/model/ProjectMetadata.java b/src/main/java/org/dependencytrack/model/ProjectMetadata.java index 6fb10e278..5f072904c 100644 --- a/src/main/java/org/dependencytrack/model/ProjectMetadata.java +++ b/src/main/java/org/dependencytrack/model/ProjectMetadata.java @@ -18,11 +18,7 @@ */ package org.dependencytrack.model; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonInclude.Include; -import org.dependencytrack.persistence.converter.OrganizationalContactsJsonConverter; -import org.dependencytrack.persistence.converter.OrganizationalEntityJsonConverter; +import java.util.List; import javax.jdo.annotations.Column; import javax.jdo.annotations.Convert; @@ -31,7 +27,14 @@ import javax.jdo.annotations.Persistent; import javax.jdo.annotations.PrimaryKey; import javax.jdo.annotations.Unique; -import java.util.List; + +import org.dependencytrack.persistence.converter.MetadataPropertyJsonConverter; +import org.dependencytrack.persistence.converter.OrganizationalContactsJsonConverter; +import org.dependencytrack.persistence.converter.OrganizationalEntityJsonConverter; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; /** * Metadata that relates to, but does not directly describe, a {@link Project}. @@ -66,6 +69,11 @@ public class ProjectMetadata { @Column(name = "AUTHORS", jdbcType = "CLOB", allowsNull = "true") private List authors; + @Persistent(defaultFetchGroup = "true") + @Convert(MetadataPropertyJsonConverter.class) + @Column(name = "PROPERTIES", jdbcType = "CLOB", allowsNull = "true") + private List properties; + public long getId() { return id; } @@ -98,4 +106,11 @@ public void setAuthors(final List authors) { this.authors = authors; } + public List getProperties() { + return properties; + } + + public void setProperties(final List properties) { + this.properties = properties; + } } diff --git a/src/main/java/org/dependencytrack/parser/cyclonedx/util/ModelConverter.java b/src/main/java/org/dependencytrack/parser/cyclonedx/util/ModelConverter.java index 347b4c5fb..27800545c 100644 --- a/src/main/java/org/dependencytrack/parser/cyclonedx/util/ModelConverter.java +++ b/src/main/java/org/dependencytrack/parser/cyclonedx/util/ModelConverter.java @@ -21,12 +21,15 @@ import alpine.common.logging.Logger; import alpine.model.IConfigProperty; import alpine.model.IConfigProperty.PropertyType; + import com.github.packageurl.MalformedPackageURLException; import com.github.packageurl.PackageURL; + import jakarta.json.Json; import jakarta.json.JsonArray; import jakarta.json.JsonObject; import jakarta.json.JsonValue; + import org.apache.commons.collections4.MultiValuedMap; import org.apache.commons.collections4.multimap.HashSetValuedHashMap; import org.apache.commons.lang3.StringUtils; @@ -76,9 +79,11 @@ import java.util.function.Function; import static java.util.Objects.requireNonNullElse; + import static org.apache.commons.lang3.StringUtils.isNotBlank; import static org.apache.commons.lang3.StringUtils.trim; import static org.apache.commons.lang3.StringUtils.trimToNull; +import org.dependencytrack.model.MetadataProperty; import static org.dependencytrack.util.PurlUtil.silentPurlCoordinatesOnly; public class ModelConverter { @@ -97,6 +102,7 @@ public static ProjectMetadata convertToProjectMetadata(final org.cyclonedx.model } final var projectMetadata = new ProjectMetadata(); + projectMetadata.setProperties(convertCdxProperties(cdxMetadata.getProperties())); projectMetadata.setAuthors(convertCdxContacts(cdxMetadata.getAuthors())); projectMetadata.setSupplier(convert(cdxMetadata.getSupplier())); @@ -488,6 +494,27 @@ public static List convertCdxContacts(final List convertCdxProperties(final List cdxProperties) { + if (cdxProperties == null) { + return null; + } + + return cdxProperties.stream().map(ModelConverter::convert).toList(); + } + + + private static MetadataProperty convert(final org.cyclonedx.model.Property cdxProperty) { + if (cdxProperty == null) { + return null; + } + + final var property = new MetadataProperty(); + property.setName(StringUtils.trimToNull(cdxProperty.getName())); + property.setValue(StringUtils.trimToNull(cdxProperty.getValue())); + return property; + } + private static OrganizationalContact convert(final org.cyclonedx.model.OrganizationalContact cdxContact) { if (cdxContact == null) { return null; @@ -508,6 +535,14 @@ private static List convertContacts(f return dtContacts.stream().map(ModelConverter::convert).toList(); } + private static List convertMetadataProperties(final List dtProperties) { + if (dtProperties == null) { + return null; + } + + return dtProperties.stream().map(ModelConverter::convert).toList(); + } + private static org.cyclonedx.model.OrganizationalEntity convert(final OrganizationalEntity dtEntity) { if (dtEntity == null) { return null; @@ -525,6 +560,18 @@ private static org.cyclonedx.model.OrganizationalEntity convert(final Organizati return cdxEntity; } + private static org.cyclonedx.model.Property convert(final MetadataProperty dtProperty) { + if (dtProperty == null) { + return null; + } + + final var cdxProperty = new org.cyclonedx.model.Property(); + cdxProperty.setName(StringUtils.trimToNull(dtProperty.getName())); + cdxProperty.setValue(StringUtils.trimToNull(dtProperty.getValue())); + + return cdxProperty; + } + private static org.cyclonedx.model.OrganizationalContact convert(final OrganizationalContact dtContact) { if (dtContact == null) { return null; @@ -756,6 +803,7 @@ public static org.cyclonedx.model.Metadata createMetadata(final Project project) if (project.getMetadata() != null) { metadata.setAuthors(convertContacts(project.getMetadata().getAuthors())); metadata.setSupplier(convert(project.getMetadata().getSupplier())); + metadata.setProperties(convertMetadataProperties(project.getMetadata().getProperties())); } } return metadata; diff --git a/src/main/java/org/dependencytrack/persistence/converter/MetadataPropertyJsonConverter.java b/src/main/java/org/dependencytrack/persistence/converter/MetadataPropertyJsonConverter.java new file mode 100644 index 000000000..b36bb36d7 --- /dev/null +++ b/src/main/java/org/dependencytrack/persistence/converter/MetadataPropertyJsonConverter.java @@ -0,0 +1,45 @@ +/* + * 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.persistence.converter; + +import java.util.List; + +import org.dependencytrack.model.MetadataProperty; + +import com.fasterxml.jackson.core.type.TypeReference; + +public class MetadataPropertyJsonConverter extends AbstractJsonConverter> { + + public MetadataPropertyJsonConverter() { + super(new TypeReference<>() {}); + } + + @Override + public String convertToDatastore(final List attributeValue) { + // Overriding is required for DataNucleus to correctly detect the return type. + return super.convertToDatastore(attributeValue); + } + + @Override + public List convertToAttribute(final String datastoreValue) { + // Overriding is required for DataNucleus to correctly detect the return type. + return super.convertToAttribute(datastoreValue); + } + +} diff --git a/src/main/java/org/dependencytrack/resources/v1/BomResource.java b/src/main/java/org/dependencytrack/resources/v1/BomResource.java index ddfd9c7f8..bfb2d2a6f 100644 --- a/src/main/java/org/dependencytrack/resources/v1/BomResource.java +++ b/src/main/java/org/dependencytrack/resources/v1/BomResource.java @@ -147,6 +147,7 @@ public Response exportProjectAsCycloneDx ( if (project == null) { return Response.status(Response.Status.NOT_FOUND).entity("The project could not be found.").build(); } + if (! qm.hasAccess(super.getPrincipal(), project)) { return Response.status(Response.Status.FORBIDDEN).entity("Access to the specified project is forbidden").build(); } diff --git a/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java b/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java index 30fa40a10..5b7614bde 100644 --- a/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java +++ b/src/test/java/org/dependencytrack/resources/v1/BomResourceTest.java @@ -18,12 +18,20 @@ */ package org.dependencytrack.resources.v1; -import alpine.common.util.UuidUtil; -import alpine.model.IConfigProperty; -import alpine.server.filters.ApiFilter; -import alpine.server.filters.AuthenticationFilter; -import com.fasterxml.jackson.core.StreamReadConstraints; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.apache.commons.io.IOUtils.resourceToByteArray; +import static org.apache.commons.io.IOUtils.resourceToString; import org.apache.http.HttpStatus; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; import org.dependencytrack.JerseyTestRule; import org.dependencytrack.ResourceTest; import org.dependencytrack.auth.Permissions; @@ -33,6 +41,10 @@ import org.dependencytrack.model.Classifier; import org.dependencytrack.model.Component; import org.dependencytrack.model.ComponentProperty; +import static org.dependencytrack.model.ConfigPropertyConstants.BOM_VALIDATION_MODE; +import static org.dependencytrack.model.ConfigPropertyConstants.BOM_VALIDATION_TAGS_EXCLUSIVE; +import static org.dependencytrack.model.ConfigPropertyConstants.BOM_VALIDATION_TAGS_INCLUSIVE; +import org.dependencytrack.model.MetadataProperty; import org.dependencytrack.model.OrganizationalContact; import org.dependencytrack.model.OrganizationalEntity; import org.dependencytrack.model.Project; @@ -50,34 +62,24 @@ import org.glassfish.jersey.media.multipart.FormDataMultiPart; import org.glassfish.jersey.media.multipart.MultiPartFeature; import org.glassfish.jersey.server.ResourceConfig; +import static org.hamcrest.CoreMatchers.equalTo; import org.junit.Assert; import org.junit.ClassRule; import org.junit.Test; +import com.fasterxml.jackson.core.StreamReadConstraints; + +import alpine.common.util.UuidUtil; +import alpine.model.IConfigProperty; +import alpine.server.filters.ApiFilter; +import alpine.server.filters.AuthenticationFilter; import jakarta.json.JsonObject; import jakarta.ws.rs.client.ClientBuilder; import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Base64; -import java.util.Collections; -import java.util.List; -import java.util.UUID; -import java.util.stream.Collectors; -import java.util.stream.Stream; - import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json; -import static org.apache.commons.io.IOUtils.resourceToByteArray; -import static org.apache.commons.io.IOUtils.resourceToString; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatNoException; -import static org.dependencytrack.model.ConfigPropertyConstants.BOM_VALIDATION_MODE; -import static org.dependencytrack.model.ConfigPropertyConstants.BOM_VALIDATION_TAGS_EXCLUSIVE; -import static org.dependencytrack.model.ConfigPropertyConstants.BOM_VALIDATION_TAGS_INCLUSIVE; -import static org.hamcrest.CoreMatchers.equalTo; public class BomResourceTest extends ResourceTest { @@ -158,6 +160,10 @@ public void exportProjectAsCycloneDxInventoryTest() { projectMetadata.setProject(project); projectMetadata.setAuthors(List.of(bomAuthor)); projectMetadata.setSupplier(bomSupplier); + final var metadataProperty = new MetadataProperty(); + metadataProperty.setName("foo"); + metadataProperty.setValue("bar"); + projectMetadata.setProperties(List.of(metadataProperty)); qm.persist(projectMetadata); final var componentSupplier = new OrganizationalEntity(); @@ -240,87 +246,93 @@ public void exportProjectAsCycloneDxInventoryTest() { "metadata": { "timestamp": "${json-unit.any-string}", "authors": [ - { + { "name": "bomAuthor" - } + } ], "component": { - "type": "application", - "bom-ref": "${json-unit.matches:projectUuid}", - "author": "SampleAuthor", - "supplier": { - "name": "projectSupplier" - }, - "name": "acme-app", - "version": "" + "type": "application", + "bom-ref": "${json-unit.matches:projectUuid}", + "author": "SampleAuthor", + "supplier": { + "name": "projectSupplier" + }, + "name": "acme-app", + "version": "" }, "manufacture": { - "name": "projectManufacturer" + "name": "projectManufacturer" }, + "properties": [ + { + "name": "foo", + "value": "bar" + } + ], "supplier": { - "name": "bomSupplier" + "name": "bomSupplier" }, "tools": [ - { - "vendor": "OWASP", - "name": "Dependency-Track", - "version": "${json-unit.any-string}" - } + { + "vendor": "OWASP", + "name": "Dependency-Track", + "version": "${json-unit.any-string}" + } ] }, "components": [ { - "type": "library", - "bom-ref": "${json-unit.matches:componentWithoutVulnUuid}", - "supplier": { - "name": "componentSupplier" - }, - "name": "acme-lib-a", - "version": "1.0.0", - "properties": [ - { - "name": "foo:bar", - "value": "baz" - } - ] + "type": "library", + "bom-ref": "${json-unit.matches:componentWithoutVulnUuid}", + "supplier": { + "name": "componentSupplier" + }, + "name": "acme-lib-a", + "version": "1.0.0", + "properties": [ + { + "name": "foo:bar", + "value": "baz" + } + ] }, { - "type": "library", - "bom-ref": "${json-unit.matches:componentWithVulnUuid}", - "name": "acme-lib-b", - "version": "1.0.0" + "type": "library", + "bom-ref": "${json-unit.matches:componentWithVulnUuid}", + "name": "acme-lib-b", + "version": "1.0.0" }, { - "type": "library", - "bom-ref": "${json-unit.matches:componentWithVulnAndAnalysisUuid}", - "name": "acme-lib-c", - "version": "1.0.0" + "type": "library", + "bom-ref": "${json-unit.matches:componentWithVulnAndAnalysisUuid}", + "name": "acme-lib-c", + "version": "1.0.0" } ], "dependencies": [ { - "ref": "${json-unit.matches:projectUuid}", - "dependsOn": [ - "${json-unit.matches:componentWithoutVulnUuid}", - "${json-unit.matches:componentWithVulnAndAnalysisUuid}" - ] + "ref": "${json-unit.matches:projectUuid}", + "dependsOn": [ + "${json-unit.matches:componentWithoutVulnUuid}", + "${json-unit.matches:componentWithVulnAndAnalysisUuid}" + ] }, { - "ref": "${json-unit.matches:componentWithoutVulnUuid}", - "dependsOn": [ - "${json-unit.matches:componentWithVulnUuid}" - ] + "ref": "${json-unit.matches:componentWithoutVulnUuid}", + "dependsOn": [ + "${json-unit.matches:componentWithVulnUuid}" + ] }, { - "ref": "${json-unit.matches:componentWithVulnUuid}", - "dependsOn": [] + "ref": "${json-unit.matches:componentWithVulnUuid}", + "dependsOn": [] }, { - "ref": "${json-unit.matches:componentWithVulnAndAnalysisUuid}", - "dependsOn": [] + "ref": "${json-unit.matches:componentWithVulnAndAnalysisUuid}", + "dependsOn": [] } ] - } + } """)); // Ensure the dependency graph did not get deleted during export. diff --git a/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java b/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java index 100241138..0e00c8bca 100644 --- a/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java +++ b/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java @@ -207,6 +207,12 @@ public void informTest() throws Exception { assertThat(contact.getPhone()).isEqualTo("123-456-7890"); }); }); + assertThat(project.getMetadata().getProperties()).isNotNull(); + assertThat(project.getMetadata().getProperties()).hasSize(2); + assertThat(project.getMetadata().getProperties().get(0).getName()).isEqualTo("BUILD_SYSTEM"); + assertThat(project.getMetadata().getProperties().get(0).getValue()).isEqualTo("Maven"); + assertThat(project.getMetadata().getProperties().get(1).getName()).isEqualTo("LANGUAGE"); + assertThat(project.getMetadata().getProperties().get(1).getValue()).isEqualTo("Java"); final List components = qm.getAllComponents(project); assertThat(components).hasSize(1); diff --git a/src/test/resources/unit/bom-1.xml b/src/test/resources/unit/bom-1.xml index 1a91de235..53a43fadd 100644 --- a/src/test/resources/unit/bom-1.xml +++ b/src/test/resources/unit/bom-1.xml @@ -58,6 +58,16 @@ 123-456-7890 + + + BUILD_SYSTEM + Maven + + + LANGUAGE + Java + +