From e439368c1c23c12348509a68e44463c65f7e4749 Mon Sep 17 00:00:00 2001 From: nscuro Date: Wed, 14 Feb 2024 13:06:54 +0100 Subject: [PATCH] Ingest `metadata.tools` from BOMs and make it available in CEL policies The internal model is aligned with CycloneDX v1.5, in that it differentiates between tools that are components, and tools that are services: https://cyclonedx.org/docs/1.5/json/#tab-pane_metadata_tools_oneOf_i0 When ingesting BOMs following v1.4 or older of the CycloneDX specification, `metadata.tools` array items will be converted to `metadata.tools.components`. For the time being, tools are persisted as JSON column in the `PROJECT_METADATA` table. As such, tools will not be analyzed for vulnerabilities or other kinds of risk. Tool components and services are treated as subsets of the internal `Component` and `ServiceComponent` models. This subset property is enforced via Jackson's `@JsonView`s, such that only specific fields are considered when serializing and deserializing to and from JSON. Tools are made available in CEL policy expressions under `project.metadata.tools.components`. Tool components use the existing `v1.Component` type, which means that functions like `matches_version` can be used on them. Signed-off-by: nscuro --- .../org/dependencytrack/model/Component.java | 24 ++ .../model/DataClassification.java | 4 + .../model/ExternalReference.java | 4 + .../org/dependencytrack/model/JsonViews.java | 16 + .../model/OrganizationalContact.java | 4 + .../model/OrganizationalEntity.java | 4 + .../model/ProjectMetadata.java | 14 + .../model/ServiceComponent.java | 11 + .../java/org/dependencytrack/model/Tools.java | 12 + .../model/mapping/PolicyProtoMapper.java | 80 +++++ .../parser/cyclonedx/util/ModelConverter.java | 84 ++++- .../converter/AbstractJsonConverter.java | 38 ++- .../converter/ToolsJsonConverter.java | 25 ++ .../policy/cel/CelCommonPolicyLibrary.java | 3 + .../policy/cel/CelPolicyEngine.java | 48 --- .../cel/CelVulnerabilityPolicyEvaluator.java | 6 + .../policy/cel/definition/CelPolicyTypes.java | 3 + .../policy/cel/mapping/ProjectProjection.java | 3 - .../policy/cel/persistence/CelPolicyDao.java | 12 + .../CelPolicyProjectRowMapper.java | 39 ++- .../tasks/BomUploadProcessingTask.java | 100 +++--- .../dependencytrack/policy/v1/policy.proto | 12 + .../resources/migration/changelog-v5.4.0.xml | 16 + .../model/mapping/PolicyProtoMapperTest.java | 2 +- .../converter/ToolsJsonConverterTest.java | 305 ++++++++++++++++++ .../policy/cel/CelPolicyEngineTest.java | 107 +++++- .../tasks/BomUploadProcessingTaskTest.java | 57 ++++ .../unit/bom-metadata-tool-deprecated.json | 26 ++ .../resources/unit/bom-metadata-tool.json | 47 +++ 29 files changed, 997 insertions(+), 109 deletions(-) create mode 100644 src/main/java/org/dependencytrack/model/JsonViews.java create mode 100644 src/main/java/org/dependencytrack/model/Tools.java create mode 100644 src/main/java/org/dependencytrack/persistence/converter/ToolsJsonConverter.java create mode 100644 src/main/resources/migration/changelog-v5.4.0.xml create mode 100644 src/test/java/org/dependencytrack/persistence/converter/ToolsJsonConverterTest.java create mode 100644 src/test/resources/unit/bom-metadata-tool-deprecated.json create mode 100644 src/test/resources/unit/bom-metadata-tool.json diff --git a/src/main/java/org/dependencytrack/model/Component.java b/src/main/java/org/dependencytrack/model/Component.java index f0e0b65dc..cc33f9531 100644 --- a/src/main/java/org/dependencytrack/model/Component.java +++ b/src/main/java/org/dependencytrack/model/Component.java @@ -23,6 +23,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonView; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.github.packageurl.MalformedPackageURLException; @@ -114,17 +115,20 @@ public enum FetchGroup { @Persistent @Column(name = "AUTHOR", jdbcType = "CLOB") @Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS, message = "The author may only contain printable characters") + @JsonView(JsonViews.MetadataTools.class) private String author; @Persistent @Column(name = "PUBLISHER", jdbcType = "VARCHAR") @Size(max = 255) @Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS, message = "The publisher may only contain printable characters") + @JsonView(JsonViews.MetadataTools.class) private String publisher; @Persistent(defaultFetchGroup = "true") @Convert(OrganizationalEntityJsonConverter.class) @Column(name = "SUPPLIER", jdbcType = "CLOB", allowsNull = "true") + @JsonView(JsonViews.MetadataTools.class) private OrganizationalEntity supplier; @Persistent @@ -132,6 +136,7 @@ public enum FetchGroup { @Index(name = "COMPONENT_GROUP_IDX") @Size(max = 255) @Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS, message = "The group may only contain printable characters") + @JsonView(JsonViews.MetadataTools.class) private String group; @Persistent @@ -141,6 +146,7 @@ public enum FetchGroup { @Size(min = 1, max = 255) @JsonDeserialize(using = TrimmedStringDeserializer.class) @Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS, message = "The name may only contain printable characters") + @JsonView(JsonViews.MetadataTools.class) private String name; @Persistent @@ -148,12 +154,14 @@ public enum FetchGroup { @Size(max = 255) @JsonDeserialize(using = TrimmedStringDeserializer.class) @Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS, message = "The version may only contain printable characters") + @JsonView(JsonViews.MetadataTools.class) private String version; @Persistent @Column(name = "CLASSIFIER", jdbcType = "VARCHAR") @Index(name = "COMPONENT_CLASSIFIER_IDX") @Extension(vendorName = "datanucleus", key = "enum-check-constraint", value = "true") + @JsonView(JsonViews.MetadataTools.class) private Classifier classifier; @Persistent @@ -174,72 +182,84 @@ public enum FetchGroup { @Index(name = "COMPONENT_MD5_IDX") @Column(name = "MD5", jdbcType = "VARCHAR", length = 32) @Pattern(regexp = "^[0-9a-fA-F]{32}$", message = "The MD5 hash must be a valid 32 character HEX number") + @JsonView(JsonViews.MetadataTools.class) private String md5; @Persistent @Index(name = "COMPONENT_SHA1_IDX") @Column(name = "SHA1", jdbcType = "VARCHAR", length = 40) @Pattern(regexp = "^[0-9a-fA-F]{40}$", message = "The SHA1 hash must be a valid 40 character HEX number") + @JsonView(JsonViews.MetadataTools.class) private String sha1; @Persistent @Index(name = "COMPONENT_SHA256_IDX") @Column(name = "SHA_256", jdbcType = "VARCHAR", length = 64) @Pattern(regexp = "^[0-9a-fA-F]{64}$", message = "The SHA-256 hash must be a valid 64 character HEX number") + @JsonView(JsonViews.MetadataTools.class) private String sha256; @Persistent @Index(name = "COMPONENT_SHA384_IDX") @Column(name = "SHA_384", jdbcType = "VARCHAR", length = 96) @Pattern(regexp = "^[0-9a-fA-F]{96}$", message = "The SHA-384 hash must be a valid 96 character HEX number") + @JsonView(JsonViews.MetadataTools.class) private String sha384; @Persistent @Index(name = "COMPONENT_SHA512_IDX") @Column(name = "SHA_512", jdbcType = "VARCHAR", length = 128) @Pattern(regexp = "^[0-9a-fA-F]{128}$", message = "The SHA-512 hash must be a valid 128 character HEX number") + @JsonView(JsonViews.MetadataTools.class) private String sha512; @Persistent @Index(name = "COMPONENT_SHA3_256_IDX") @Column(name = "SHA3_256", jdbcType = "VARCHAR", length = 64) @Pattern(regexp = "^[0-9a-fA-F]{64}$", message = "The SHA3-256 hash must be a valid 64 character HEX number") + @JsonView(JsonViews.MetadataTools.class) private String sha3_256; @Persistent @Index(name = "COMPONENT_SHA3_384_IDX") @Column(name = "SHA3_384", jdbcType = "VARCHAR", length = 96) @Pattern(regexp = "^[0-9a-fA-F]{96}$", message = "The SHA3-384 hash must be a valid 96 character HEX number") + @JsonView(JsonViews.MetadataTools.class) private String sha3_384; @Persistent @Index(name = "COMPONENT_SHA3_512_IDX") @Column(name = "SHA3_512", jdbcType = "VARCHAR", length = 128) @Pattern(regexp = "^[0-9a-fA-F]{128}$", message = "The SHA3-512 hash must be a valid 128 character HEX number") + @JsonView(JsonViews.MetadataTools.class) private String sha3_512; @Persistent @Index(name = "COMPONENT_BLAKE2B_256_IDX") @Column(name = "BLAKE2B_256", jdbcType = "VARCHAR", length = 64) @Pattern(regexp = RegexSequence.Definition.HASH_SHA256, message = "The BLAKE2b hash must be a valid 64 character HEX number") + @JsonView(JsonViews.MetadataTools.class) private String blake2b_256; @Persistent @Index(name = "COMPONENT_BLAKE2B_384_IDX") @Column(name = "BLAKE2B_384", jdbcType = "VARCHAR", length = 96) @Pattern(regexp = RegexSequence.Definition.HASH_SHA384, message = "The BLAKE2b hash must be a valid 96 character HEX number") + @JsonView(JsonViews.MetadataTools.class) private String blake2b_384; @Persistent @Index(name = "COMPONENT_BLAKE2B_512_IDX") @Column(name = "BLAKE2B_512", jdbcType = "VARCHAR", length = 128) @Pattern(regexp = RegexSequence.Definition.HASH_SHA512, message = "The BLAKE2b hash must be a valid 128 character HEX number") + @JsonView(JsonViews.MetadataTools.class) private String blake2b_512; @Persistent @Index(name = "COMPONENT_BLAKE3_IDX") @Column(name = "BLAKE3", jdbcType = "VARCHAR", length = 255) @Pattern(regexp = RegexSequence.Definition.HEXADECIMAL, message = "The BLAKE3 hash must be a valid HEX number") + @JsonView(JsonViews.MetadataTools.class) private String blake3; @Persistent @@ -248,6 +268,7 @@ public enum FetchGroup { @Size(max = 255) //Patterns obtained from https://csrc.nist.gov/schema/cpe/2.3/cpe-naming_2.3.xsd @Pattern(regexp = "(cpe:2\\.3:[aho\\*\\-](:(((\\?*|\\*?)([a-zA-Z0-9\\-\\._]|(\\\\[\\\\\\*\\?!\"#$$%&'\\(\\)\\+,/:;<=>@\\[\\]\\^`\\{\\|}~]))+(\\?*|\\*?))|[\\*\\-])){5}(:(([a-zA-Z]{2,3}(-([a-zA-Z]{2}|[0-9]{3}))?)|[\\*\\-]))(:(((\\?*|\\*?)([a-zA-Z0-9\\-\\._]|(\\\\[\\\\\\*\\?!\"#$$%&'\\(\\)\\+,/:;<=>@\\[\\]\\^`\\{\\|}~]))+(\\?*|\\*?))|[\\*\\-])){4})|([c][pP][eE]:/[AHOaho]?(:[A-Za-z0-9\\._\\-~%]*){0,6})", message = "The CPE must conform to the CPE v2.2 or v2.3 specification defined by NIST") + @JsonView(JsonViews.MetadataTools.class) private String cpe; @Persistent(defaultFetchGroup = "true") @@ -256,6 +277,7 @@ public enum FetchGroup { @Size(max = 1024) @com.github.packageurl.validator.PackageURL @JsonDeserialize(using = TrimmedStringDeserializer.class) + @JsonView(JsonViews.MetadataTools.class) private String purl; @Persistent(defaultFetchGroup = "true") @@ -270,6 +292,7 @@ public enum FetchGroup { @Index(name = "COMPONENT_SWID_TAGID_IDX") @Size(max = 255) @Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS, message = "The SWID tagId may only contain printable characters") + @JsonView(JsonViews.MetadataTools.class) private String swidTagId; @Persistent @@ -323,6 +346,7 @@ public enum FetchGroup { @Persistent(defaultFetchGroup = "true") @Column(name = "EXTERNAL_REFERENCES") @Serialized + @JsonView(JsonViews.MetadataTools.class) private List externalReferences; @Persistent diff --git a/src/main/java/org/dependencytrack/model/DataClassification.java b/src/main/java/org/dependencytrack/model/DataClassification.java index 5092e777d..8238dc5ce 100644 --- a/src/main/java/org/dependencytrack/model/DataClassification.java +++ b/src/main/java/org/dependencytrack/model/DataClassification.java @@ -19,6 +19,7 @@ package org.dependencytrack.model; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonView; import java.io.Serializable; @@ -50,7 +51,10 @@ private Direction(String name) { } } + @JsonView(JsonViews.MetadataTools.class) private Direction direction; + + @JsonView(JsonViews.MetadataTools.class) private String name; public Direction getDirection() { diff --git a/src/main/java/org/dependencytrack/model/ExternalReference.java b/src/main/java/org/dependencytrack/model/ExternalReference.java index 1bd2925ac..e1b7a344b 100644 --- a/src/main/java/org/dependencytrack/model/ExternalReference.java +++ b/src/main/java/org/dependencytrack/model/ExternalReference.java @@ -21,6 +21,7 @@ import alpine.common.validation.RegexSequence; import alpine.server.json.TrimmedStringDeserializer; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonView; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import javax.validation.constraints.NotBlank; @@ -38,14 +39,17 @@ public class ExternalReference implements Serializable { private static final long serialVersionUID = -5885851731192037664L; + @JsonView(JsonViews.MetadataTools.class) private org.cyclonedx.model.ExternalReference.Type type; @NotBlank @JsonDeserialize(using = TrimmedStringDeserializer.class) + @JsonView(JsonViews.MetadataTools.class) private String url; @JsonDeserialize(using = TrimmedStringDeserializer.class) @Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS, message = "The comment may only contain printable characters") + @JsonView(JsonViews.MetadataTools.class) private String comment; public org.cyclonedx.model.ExternalReference.Type getType() { diff --git a/src/main/java/org/dependencytrack/model/JsonViews.java b/src/main/java/org/dependencytrack/model/JsonViews.java new file mode 100644 index 000000000..9401cae31 --- /dev/null +++ b/src/main/java/org/dependencytrack/model/JsonViews.java @@ -0,0 +1,16 @@ +package org.dependencytrack.model; + +import com.fasterxml.jackson.annotation.JsonView; + +/** + * Marker interfaces to be used in conjunction with Jackson's {@link JsonView} annotation. + */ +public class JsonViews { + + /** + * Marks fields to be included when (de-)serializing {@link Tools}. + */ + public interface MetadataTools { + } + +} diff --git a/src/main/java/org/dependencytrack/model/OrganizationalContact.java b/src/main/java/org/dependencytrack/model/OrganizationalContact.java index 347bfd89e..ddd069009 100644 --- a/src/main/java/org/dependencytrack/model/OrganizationalContact.java +++ b/src/main/java/org/dependencytrack/model/OrganizationalContact.java @@ -20,6 +20,7 @@ import alpine.server.json.TrimmedStringDeserializer; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonView; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import java.io.Serializable; @@ -37,12 +38,15 @@ public class OrganizationalContact implements Serializable { private static final long serialVersionUID = -1026863376484187244L; @JsonDeserialize(using = TrimmedStringDeserializer.class) + @JsonView(JsonViews.MetadataTools.class) private String name; @JsonDeserialize(using = TrimmedStringDeserializer.class) + @JsonView(JsonViews.MetadataTools.class) private String email; @JsonDeserialize(using = TrimmedStringDeserializer.class) + @JsonView(JsonViews.MetadataTools.class) private String phone; public String getName() { diff --git a/src/main/java/org/dependencytrack/model/OrganizationalEntity.java b/src/main/java/org/dependencytrack/model/OrganizationalEntity.java index e8f682bac..2ab46493c 100644 --- a/src/main/java/org/dependencytrack/model/OrganizationalEntity.java +++ b/src/main/java/org/dependencytrack/model/OrganizationalEntity.java @@ -21,6 +21,7 @@ import alpine.server.json.TrimmedStringArrayDeserializer; import alpine.server.json.TrimmedStringDeserializer; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonView; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import java.io.Serializable; @@ -41,11 +42,14 @@ public class OrganizationalEntity implements Serializable { private static final long serialVersionUID = 5333594855427723634L; @JsonDeserialize(using = TrimmedStringDeserializer.class) + @JsonView(JsonViews.MetadataTools.class) private String name; @JsonDeserialize(using = TrimmedStringArrayDeserializer.class) + @JsonView(JsonViews.MetadataTools.class) private String[] urls; + @JsonView(JsonViews.MetadataTools.class) private List contacts; public String getName() { diff --git a/src/main/java/org/dependencytrack/model/ProjectMetadata.java b/src/main/java/org/dependencytrack/model/ProjectMetadata.java index ddc71647e..5378d91dd 100644 --- a/src/main/java/org/dependencytrack/model/ProjectMetadata.java +++ b/src/main/java/org/dependencytrack/model/ProjectMetadata.java @@ -23,6 +23,7 @@ import com.fasterxml.jackson.annotation.JsonInclude.Include; import org.dependencytrack.persistence.converter.OrganizationalContactsJsonConverter; import org.dependencytrack.persistence.converter.OrganizationalEntityJsonConverter; +import org.dependencytrack.persistence.converter.ToolsJsonConverter; import javax.jdo.annotations.Column; import javax.jdo.annotations.Convert; @@ -66,6 +67,11 @@ public class ProjectMetadata { @Column(name = "AUTHORS", jdbcType = "CLOB", allowsNull = "true") private List authors; + @Persistent(defaultFetchGroup = "true") + @Convert(ToolsJsonConverter.class) + @Column(name = "TOOLS", jdbcType = "CLOB", allowsNull = "true") + private Tools tools; + public long getId() { return id; } @@ -98,4 +104,12 @@ public void setAuthors(final List authors) { this.authors = authors; } + public Tools getTools() { + return tools; + } + + public void setTools(final Tools tools) { + this.tools = tools; + } + } \ No newline at end of file diff --git a/src/main/java/org/dependencytrack/model/ServiceComponent.java b/src/main/java/org/dependencytrack/model/ServiceComponent.java index eeae2042b..5f34b968c 100644 --- a/src/main/java/org/dependencytrack/model/ServiceComponent.java +++ b/src/main/java/org/dependencytrack/model/ServiceComponent.java @@ -23,6 +23,7 @@ import alpine.server.json.TrimmedStringDeserializer; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonView; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import javax.jdo.annotations.Column; @@ -86,12 +87,14 @@ public enum FetchGroup { @Persistent(defaultFetchGroup = "true") @Column(name = "PROVIDER_ID") @Serialized + @JsonView(JsonViews.MetadataTools.class) private OrganizationalEntity provider; @Persistent @Column(name = "GROUP", jdbcType = "VARCHAR") @Size(max = 255) @Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS, message = "The group may only contain printable characters") + @JsonView(JsonViews.MetadataTools.class) private String group; @Persistent @@ -100,6 +103,7 @@ public enum FetchGroup { @Size(min = 1, max = 255) @JsonDeserialize(using = TrimmedStringDeserializer.class) @Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS, message = "The name may only contain printable characters") + @JsonView(JsonViews.MetadataTools.class) private String name; @Persistent @@ -107,6 +111,7 @@ public enum FetchGroup { @Size(max = 255) @JsonDeserialize(using = TrimmedStringDeserializer.class) @Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS, message = "The version may only contain printable characters") + @JsonView(JsonViews.MetadataTools.class) private String version; @Persistent @@ -114,25 +119,30 @@ public enum FetchGroup { @Size(max = 1024) @JsonDeserialize(using = TrimmedStringDeserializer.class) @Pattern(regexp = RegexSequence.Definition.PRINTABLE_CHARS, message = "The description may only contain printable characters") + @JsonView(JsonViews.MetadataTools.class) private String description; @Persistent(defaultFetchGroup = "true") @Serialized @Column(name = "ENDPOINTS", jdbcType = "LONGVARBINARY") @JsonDeserialize(using = TrimmedStringArrayDeserializer.class) + @JsonView(JsonViews.MetadataTools.class) private String[] endpoints; @Persistent @Column(name = "AUTHENTICATED") + @JsonView(JsonViews.MetadataTools.class) private Boolean authenticated; @Persistent @Column(name = "X_TRUST_BOUNDARY") + @JsonView(JsonViews.MetadataTools.class) private Boolean crossesTrustBoundary; @Persistent(defaultFetchGroup = "true") @Column(name = "DATA") @Serialized + @JsonView(JsonViews.MetadataTools.class) private List data; //TODO add license support once Component license support is refactored @@ -140,6 +150,7 @@ public enum FetchGroup { @Persistent(defaultFetchGroup = "true") @Column(name = "EXTERNAL_REFERENCES") @Serialized + @JsonView(JsonViews.MetadataTools.class) private List externalReferences; @Persistent diff --git a/src/main/java/org/dependencytrack/model/Tools.java b/src/main/java/org/dependencytrack/model/Tools.java new file mode 100644 index 000000000..0d0753a74 --- /dev/null +++ b/src/main/java/org/dependencytrack/model/Tools.java @@ -0,0 +1,12 @@ +package org.dependencytrack.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonView; + +import java.util.List; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record Tools( + @JsonView(JsonViews.MetadataTools.class) List components, + @JsonView(JsonViews.MetadataTools.class) List services) { +} diff --git a/src/main/java/org/dependencytrack/model/mapping/PolicyProtoMapper.java b/src/main/java/org/dependencytrack/model/mapping/PolicyProtoMapper.java index 03513167c..df4b9d485 100644 --- a/src/main/java/org/dependencytrack/model/mapping/PolicyProtoMapper.java +++ b/src/main/java/org/dependencytrack/model/mapping/PolicyProtoMapper.java @@ -2,6 +2,9 @@ import com.google.protobuf.Timestamp; import com.google.protobuf.util.Timestamps; +import org.dependencytrack.model.Component; +import org.dependencytrack.model.License; +import org.dependencytrack.model.LicenseGroup; import org.dependencytrack.model.Vulnerability; import org.dependencytrack.model.VulnerabilityAlias; @@ -20,6 +23,43 @@ */ public class PolicyProtoMapper { + public static org.dependencytrack.proto.policy.v1.Component mapToProto(final Component component) { + if (component == null) { + return org.dependencytrack.proto.policy.v1.Component.getDefaultInstance(); + } + + // An object attached to a persistence context could do lazy loading of fields when accessing them. + // Ensure this can't happen, as it could cause massive performance degradation. + assertNonPersistent(component, "component must not be persistent"); + + final org.dependencytrack.proto.policy.v1.Component.Builder protoBuilder = + org.dependencytrack.proto.policy.v1.Component.newBuilder(); + maybeSet(asString(component.getUuid()), protoBuilder::setUuid); + maybeSet(component::getGroup, protoBuilder::setGroup); + maybeSet(component::getName, protoBuilder::setName); + maybeSet(component::getVersion, protoBuilder::setVersion); + maybeSet(asString(component.getClassifier()), protoBuilder::setClassifier); + maybeSet(component::getCpe, protoBuilder::setCpe); + maybeSet(component::getPurl, purl -> protoBuilder.setPurl(purl.canonicalize())); + maybeSet(component::getSwidTagId, protoBuilder::setSwidTagId); + maybeSet(component::isInternal, protoBuilder::setIsInternal); + maybeSet(component::getMd5, protoBuilder::setMd5); + maybeSet(component::getSha1, protoBuilder::setSha1); + maybeSet(component::getSha256, protoBuilder::setSha256); + maybeSet(component::getSha384, protoBuilder::setSha384); + maybeSet(component::getSha512, protoBuilder::setSha512); + maybeSet(component::getSha3_256, protoBuilder::setSha3256); + maybeSet(component::getSha3_384, protoBuilder::setSha3384); + maybeSet(component::getSha3_512, protoBuilder::setSha3512); + maybeSet(component::getBlake2b_256, protoBuilder::setBlake2B256); + maybeSet(component::getBlake2b_384, protoBuilder::setBlake2B384); + maybeSet(component::getBlake2b_512, protoBuilder::setBlake2B512); + maybeSet(component::getBlake3, protoBuilder::setBlake3); + maybeSet(component::getResolvedLicense, license -> protoBuilder.setResolvedLicense(mapToProto(license))); + + return protoBuilder.build(); + } + public static org.dependencytrack.proto.policy.v1.Vulnerability mapToProto(final Vulnerability vuln) { if (vuln == null) { return org.dependencytrack.proto.policy.v1.Vulnerability.getDefaultInstance(); @@ -61,6 +101,46 @@ public static org.dependencytrack.proto.policy.v1.Vulnerability mapToProto(final return protoBuilder.build(); } + private static org.dependencytrack.proto.policy.v1.License mapToProto(final License license) { + if (license == null) { + return org.dependencytrack.proto.policy.v1.License.getDefaultInstance(); + } + + // An object attached to a persistence context could do lazy loading of fields when accessing them. + // Ensure this can't happen, as it could cause massive performance degradation. + assertNonPersistent(license, "license must not be persistent"); + + final org.dependencytrack.proto.policy.v1.License.Builder protoBuilder = + org.dependencytrack.proto.policy.v1.License.newBuilder(); + maybeSet(asString(license.getUuid()), protoBuilder::setUuid); + maybeSet(license::getLicenseId, protoBuilder::setId); + maybeSet(license::getName, protoBuilder::setName); + maybeSet(license::isOsiApproved, protoBuilder::setIsOsiApproved); + maybeSet(license::isFsfLibre, protoBuilder::setIsFsfLibre); + maybeSet(license::isDeprecatedLicenseId, protoBuilder::setIsDeprecatedId); + maybeSet(license::isCustomLicense, protoBuilder::setIsCustom); + maybeSet(license::getLicenseGroups, licenseGroups -> licenseGroups.stream() + .map(PolicyProtoMapper::mapToProto).forEach(protoBuilder::addGroups)); + + return protoBuilder.build(); + } + + private static org.dependencytrack.proto.policy.v1.License.Group mapToProto(final LicenseGroup licenseGroup) { + if (licenseGroup == null) { + return org.dependencytrack.proto.policy.v1.License.Group.getDefaultInstance(); + } + + // An object attached to a persistence context could do lazy loading of fields when accessing them. + // Ensure this can't happen, as it could cause massive performance degradation. + assertNonPersistent(licenseGroup, "licenseGroup must not be persistent"); + + final org.dependencytrack.proto.policy.v1.License.Group.Builder protoBuilder = + org.dependencytrack.proto.policy.v1.License.Group.newBuilder(); + maybeSet(asString(licenseGroup.getUuid()), protoBuilder::setUuid); + maybeSet(licenseGroup::getName, protoBuilder::setName); + return protoBuilder.build(); + } + private static Stream mapToProtos(final VulnerabilityAlias alias) { if (alias == null) { return Stream.empty(); 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 d3a41ec6e..a9e11aa88 100644 --- a/src/main/java/org/dependencytrack/parser/cyclonedx/util/ModelConverter.java +++ b/src/main/java/org/dependencytrack/parser/cyclonedx/util/ModelConverter.java @@ -27,6 +27,7 @@ import org.cyclonedx.model.LicenseChoice; import org.cyclonedx.model.Metadata; import org.cyclonedx.model.Swid; +import org.cyclonedx.model.Tool; import org.dependencytrack.model.Analysis; import org.dependencytrack.model.AnalysisJustification; import org.dependencytrack.model.AnalysisResponse; @@ -43,6 +44,7 @@ import org.dependencytrack.model.ProjectMetadata; import org.dependencytrack.model.ServiceComponent; import org.dependencytrack.model.Severity; +import org.dependencytrack.model.Tools; import org.dependencytrack.model.Vulnerability; import org.dependencytrack.parser.common.resolver.CweResolver; import org.dependencytrack.parser.cyclonedx.CycloneDXExporter; @@ -83,14 +85,49 @@ private ModelConverter() { } public static ProjectMetadata convertToProjectMetadata(final Metadata cdxMetadata) { + if (cdxMetadata == null) { + return null; + } + final var projectMetadata = new ProjectMetadata(); projectMetadata.setSupplier(ModelConverter.convert(cdxMetadata.getSupplier())); projectMetadata.setAuthors(ModelConverter.convertCdxContacts(cdxMetadata.getAuthors())); + + final var toolComponents = new ArrayList(); + final var toolServices = new ArrayList(); + if (cdxMetadata.getTools() != null) { + cdxMetadata.getTools().stream().map(ModelConverter::convert).forEach(toolComponents::add); + } + if (cdxMetadata.getToolChoice() != null) { + if (cdxMetadata.getToolChoice().getComponents() != null) { + cdxMetadata.getToolChoice().getComponents().stream().map(ModelConverter::convertComponent).forEach(toolComponents::add); + } + if (cdxMetadata.getToolChoice().getServices() != null) { + cdxMetadata.getToolChoice().getServices().stream().map(ModelConverter::convertService).forEach(toolServices::add); + } + } + if (!toolComponents.isEmpty() || !toolServices.isEmpty()) { + projectMetadata.setTools(new Tools( + toolComponents.isEmpty() ? null : toolComponents, + toolServices.isEmpty() ? null : toolServices + )); + } + return projectMetadata; } - public static Project convertToProject(final Metadata cdxMetadata, final ProjectMetadata projectMetadata) { - final var cdxComponent = cdxMetadata.getComponent(); + public static Project convertToProject(final org.cyclonedx.model.Metadata cdxMetadata) { + if (cdxMetadata == null || cdxMetadata.getComponent() == null) { + return null; + } + + final Project project = convertToProject(cdxMetadata.getComponent()); + project.setManufacturer(convert(cdxMetadata.getManufacture())); + + return project; + } + + public static Project convertToProject(final org.cyclonedx.model.Component cdxComponent) { final var project = new Project(); project.setAuthor(trimToNull(cdxComponent.getAuthor())); project.setPublisher(trimToNull(cdxComponent.getPublisher())); @@ -100,9 +137,7 @@ public static Project convertToProject(final Metadata cdxMetadata, final Project project.setVersion(trimToNull(cdxComponent.getVersion())); project.setDescription(trimToNull(cdxComponent.getDescription())); project.setExternalReferences(convertExternalReferences(cdxComponent.getExternalReferences())); - project.setManufacturer(ModelConverter.convert(cdxMetadata.getManufacture())); project.setSupplier(ModelConverter.convert(cdxComponent.getSupplier())); - project.setMetadata(projectMetadata); if (cdxComponent.getPurl() != null) { try { @@ -229,6 +264,47 @@ public static Component convertComponent(final org.cyclonedx.model.Component cdx return component; } + private static Component convert(@SuppressWarnings("deprecation") final Tool tool) { + if (tool == null) { + return null; + } + + final var component = new Component(); + if (tool.getVendor() != null && !tool.getVendor().isBlank()) { + final var supplier = new OrganizationalEntity(); + supplier.setName(trimToNull(tool.getVendor())); + component.setSupplier(supplier); + } + component.setName(trimToNull(tool.getName())); + component.setVersion(trimToNull(tool.getVersion())); + component.setExternalReferences(convertExternalReferences(tool.getExternalReferences())); + + if (tool.getHashes() != null && !tool.getHashes().isEmpty()) { + for (final org.cyclonedx.model.Hash cdxHash : tool.getHashes()) { + final Consumer hashSetter = switch (cdxHash.getAlgorithm().toLowerCase()) { + case "md5" -> component::setMd5; + case "sha-1" -> component::setSha1; + case "sha-256" -> component::setSha256; + case "sha-384" -> component::setSha384; + case "sha-512" -> component::setSha512; + case "sha3-256" -> component::setSha3_256; + case "sha3-384" -> component::setSha3_384; + case "sha3-512" -> component::setSha3_512; + case "blake2b-256" -> component::setBlake2b_256; + case "blake2b-384" -> component::setBlake2b_384; + case "blake2b-512" -> component::setBlake2b_512; + case "blake3" -> component::setBlake3; + default -> null; + }; + if (hashSetter != null) { + hashSetter.accept(cdxHash.getValue()); + } + } + } + + return component; + } + public static OrganizationalEntity convert(final org.cyclonedx.model.OrganizationalEntity cdxEntity) { if (cdxEntity == null) { return null; diff --git a/src/main/java/org/dependencytrack/persistence/converter/AbstractJsonConverter.java b/src/main/java/org/dependencytrack/persistence/converter/AbstractJsonConverter.java index c4f9fe19f..317157ee3 100644 --- a/src/main/java/org/dependencytrack/persistence/converter/AbstractJsonConverter.java +++ b/src/main/java/org/dependencytrack/persistence/converter/AbstractJsonConverter.java @@ -19,22 +19,35 @@ package org.dependencytrack.persistence.converter; import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.json.JsonMapper; import javax.jdo.AttributeConverter; +import java.io.IOException; /** * @since 4.10.0 */ abstract class AbstractJsonConverter implements AttributeConverter { - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final JsonMapper JSON_MAPPER = JsonMapper.builder() + .disable(MapperFeature.DEFAULT_VIEW_INCLUSION) + .build(); private final TypeReference typeReference; + private final Class jsonView; AbstractJsonConverter(final TypeReference typeReference) { + this(typeReference, null); + } + + AbstractJsonConverter(final TypeReference typeReference, final Class jsonView) { this.typeReference = typeReference; + this.jsonView = jsonView; } @Override @@ -43,8 +56,15 @@ public String convertToDatastore(final T attributeValue) { return null; } + final ObjectWriter objectWriter; + if (jsonView == null) { + objectWriter = JSON_MAPPER.writer(); + } else { + objectWriter = JSON_MAPPER.writerWithView(jsonView); + } + try { - return OBJECT_MAPPER.writeValueAsString(attributeValue); + return objectWriter.writeValueAsString(attributeValue); } catch (JacksonException e) { throw new RuntimeException(e); } @@ -56,9 +76,17 @@ public T convertToAttribute(final String datastoreValue) { return null; } + final ObjectReader objectReader; + if (jsonView == null) { + objectReader = JSON_MAPPER.reader(); + } else { + objectReader = JSON_MAPPER.readerWithView(jsonView); + } + try { - return OBJECT_MAPPER.readValue(datastoreValue, typeReference); - } catch (JacksonException e) { + final JsonParser jsonParser = objectReader.createParser(datastoreValue); + return objectReader.readValue(jsonParser, typeReference); + } catch (IOException e) { throw new RuntimeException(e); } } diff --git a/src/main/java/org/dependencytrack/persistence/converter/ToolsJsonConverter.java b/src/main/java/org/dependencytrack/persistence/converter/ToolsJsonConverter.java new file mode 100644 index 000000000..aadd43184 --- /dev/null +++ b/src/main/java/org/dependencytrack/persistence/converter/ToolsJsonConverter.java @@ -0,0 +1,25 @@ +package org.dependencytrack.persistence.converter; + +import com.fasterxml.jackson.core.type.TypeReference; +import org.dependencytrack.model.JsonViews; +import org.dependencytrack.model.Tools; + +public class ToolsJsonConverter extends AbstractJsonConverter { + + public ToolsJsonConverter() { + super(new TypeReference<>() {}, JsonViews.MetadataTools.class); + } + + @Override + public String convertToDatastore(final Tools attributeValue) { + // Overriding is required for DataNucleus to correctly detect the return type. + return super.convertToDatastore(attributeValue); + } + + @Override + public Tools 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/policy/cel/CelCommonPolicyLibrary.java b/src/main/java/org/dependencytrack/policy/cel/CelCommonPolicyLibrary.java index 37e8f6e3c..f5ec3d27a 100644 --- a/src/main/java/org/dependencytrack/policy/cel/CelCommonPolicyLibrary.java +++ b/src/main/java/org/dependencytrack/policy/cel/CelCommonPolicyLibrary.java @@ -10,6 +10,7 @@ import org.dependencytrack.proto.policy.v1.Component; import org.dependencytrack.proto.policy.v1.License; import org.dependencytrack.proto.policy.v1.Project; +import org.dependencytrack.proto.policy.v1.Tools; import org.dependencytrack.proto.policy.v1.VersionDistance; import org.dependencytrack.proto.policy.v1.Vulnerability; import org.jdbi.v3.core.Handle; @@ -128,7 +129,9 @@ public List getCompileOptions() { License.getDefaultInstance(), License.Group.getDefaultInstance(), Project.getDefaultInstance(), + Project.Metadata.getDefaultInstance(), Project.Property.getDefaultInstance(), + Tools.getDefaultInstance(), Vulnerability.getDefaultInstance(), Vulnerability.Alias.getDefaultInstance(), VersionDistance.getDefaultInstance() diff --git a/src/main/java/org/dependencytrack/policy/cel/CelPolicyEngine.java b/src/main/java/org/dependencytrack/policy/cel/CelPolicyEngine.java index 85b3392b7..f38fbefae 100644 --- a/src/main/java/org/dependencytrack/policy/cel/CelPolicyEngine.java +++ b/src/main/java/org/dependencytrack/policy/cel/CelPolicyEngine.java @@ -42,8 +42,6 @@ import org.dependencytrack.policy.cel.compat.VulnerabilityIdCelPolicyScriptSourceBuilder; import org.dependencytrack.policy.cel.mapping.ComponentProjection; import org.dependencytrack.policy.cel.mapping.LicenseProjection; -import org.dependencytrack.policy.cel.mapping.ProjectProjection; -import org.dependencytrack.policy.cel.mapping.ProjectPropertyProjection; import org.dependencytrack.policy.cel.mapping.VulnerabilityProjection; import org.dependencytrack.policy.cel.persistence.CelPolicyDao; import org.dependencytrack.proto.policy.v1.Vulnerability; @@ -383,52 +381,6 @@ private static List evaluatePolicyOperators(final Collection properties = - OBJECT_MAPPER.readValue(projection.propertiesJson, new TypeReference<>() { - }); - for (final ProjectPropertyProjection property : properties) { - builder.addProperties(org.dependencytrack.proto.policy.v1.Project.Property.newBuilder() - .setGroup(trimToEmpty(property.group)) - .setName(trimToEmpty(property.name)) - .setValue(trimToEmpty(property.value)) - .setType(trimToEmpty(property.type)) - .build()); - } - } catch (JacksonException e) { - LOGGER.warn("Failed to parse properties from %s for project %s" - .formatted(projection.propertiesJson, projection.id), e); - } - } - - if (projection.tagsJson != null) { - try { - final List tags = OBJECT_MAPPER.readValue(projection.tagsJson, new TypeReference<>() { - }); - builder.addAllTags(tags); - } catch (JacksonException e) { - LOGGER.warn("Failed to parse tags from %s for project %s" - .formatted(projection.tagsJson, projection.id), e); - } - } - - return builder.build(); - } - private static org.dependencytrack.proto.policy.v1.Component mapToProto(final ComponentProjection projection, final Map protoLicenseById) { final org.dependencytrack.proto.policy.v1.Component.Builder componentBuilder = diff --git a/src/main/java/org/dependencytrack/policy/cel/CelVulnerabilityPolicyEvaluator.java b/src/main/java/org/dependencytrack/policy/cel/CelVulnerabilityPolicyEvaluator.java index 21238d817..68afa82d3 100644 --- a/src/main/java/org/dependencytrack/policy/cel/CelVulnerabilityPolicyEvaluator.java +++ b/src/main/java/org/dependencytrack/policy/cel/CelVulnerabilityPolicyEvaluator.java @@ -41,6 +41,7 @@ import static org.dependencytrack.policy.cel.definition.CelPolicyTypes.TYPE_LICENSE; import static org.dependencytrack.policy.cel.definition.CelPolicyTypes.TYPE_LICENSE_GROUP; import static org.dependencytrack.policy.cel.definition.CelPolicyTypes.TYPE_PROJECT; +import static org.dependencytrack.policy.cel.definition.CelPolicyTypes.TYPE_PROJECT_METADATA; import static org.dependencytrack.policy.cel.definition.CelPolicyTypes.TYPE_PROJECT_PROPERTY; import static org.dependencytrack.policy.cel.definition.CelPolicyTypes.TYPE_VULNERABILITY; @@ -282,6 +283,11 @@ private static String buildCacheKey(final Project project, final MultiValuedMap< cacheKeyParts.add("property.%s".formatted(propertyFieldName)); } } + if (cacheKeyParts.contains("metadata") && requirements.containsKey(TYPE_PROJECT_METADATA)) { + for (final String metadataFieldName : requirements.get(TYPE_PROJECT_METADATA)) { + cacheKeyParts.add("metadata.%s".formatted(metadataFieldName)); + } + } final String rawCacheKey = "%s|%s" .formatted(project.getUuid(), cacheKeyParts.stream().sorted().collect(Collectors.joining("|"))); diff --git a/src/main/java/org/dependencytrack/policy/cel/definition/CelPolicyTypes.java b/src/main/java/org/dependencytrack/policy/cel/definition/CelPolicyTypes.java index 672ce4e3e..821841e85 100644 --- a/src/main/java/org/dependencytrack/policy/cel/definition/CelPolicyTypes.java +++ b/src/main/java/org/dependencytrack/policy/cel/definition/CelPolicyTypes.java @@ -4,6 +4,7 @@ import org.dependencytrack.proto.policy.v1.Component; import org.dependencytrack.proto.policy.v1.License; import org.dependencytrack.proto.policy.v1.Project; +import org.dependencytrack.proto.policy.v1.Tools; import org.dependencytrack.proto.policy.v1.Vulnerability; import org.dependencytrack.proto.policy.v1.VersionDistance; import org.projectnessie.cel.checker.Decls; @@ -14,7 +15,9 @@ public class CelPolicyTypes { public static final Type TYPE_LICENSE = Decls.newObjectType(License.getDescriptor().getFullName()); public static final Type TYPE_LICENSE_GROUP = Decls.newObjectType(License.Group.getDescriptor().getFullName()); public static final Type TYPE_PROJECT = Decls.newObjectType(Project.getDescriptor().getFullName()); + public static final Type TYPE_PROJECT_METADATA = Decls.newObjectType(Project.Metadata.getDescriptor().getFullName()); public static final Type TYPE_PROJECT_PROPERTY = Decls.newObjectType(Project.Property.getDescriptor().getFullName()); + public static final Type TYPE_TOOLS = Decls.newObjectType(Tools.getDescriptor().getFullName()); public static final Type TYPE_VULNERABILITY = Decls.newObjectType(Vulnerability.getDescriptor().getFullName()); public static final Type TYPE_VULNERABILITIES = Decls.newListType(TYPE_VULNERABILITY); public static final Type TYPE_VULNERABILITY_ALIAS = Decls.newObjectType(Vulnerability.Alias.getDescriptor().getFullName()); diff --git a/src/main/java/org/dependencytrack/policy/cel/mapping/ProjectProjection.java b/src/main/java/org/dependencytrack/policy/cel/mapping/ProjectProjection.java index 40f9ab980..525bba031 100644 --- a/src/main/java/org/dependencytrack/policy/cel/mapping/ProjectProjection.java +++ b/src/main/java/org/dependencytrack/policy/cel/mapping/ProjectProjection.java @@ -38,7 +38,4 @@ public class ProjectProjection { @MappedField(protoFieldName = "last_bom_import", sqlColumnName = "LAST_BOM_IMPORTED") public Date lastBomImport; - public String propertiesJson; - public String tagsJson; - } diff --git a/src/main/java/org/dependencytrack/policy/cel/persistence/CelPolicyDao.java b/src/main/java/org/dependencytrack/policy/cel/persistence/CelPolicyDao.java index 3c8802f0e..f8c58bb0e 100644 --- a/src/main/java/org/dependencytrack/policy/cel/persistence/CelPolicyDao.java +++ b/src/main/java/org/dependencytrack/policy/cel/persistence/CelPolicyDao.java @@ -28,6 +28,7 @@ import static org.dependencytrack.policy.cel.definition.CelPolicyTypes.TYPE_COMPONENT; import static org.dependencytrack.policy.cel.definition.CelPolicyTypes.TYPE_PROJECT; +import static org.dependencytrack.policy.cel.definition.CelPolicyTypes.TYPE_PROJECT_METADATA; import static org.dependencytrack.policy.cel.definition.CelPolicyTypes.TYPE_PROJECT_PROPERTY; import static org.dependencytrack.policy.cel.definition.CelPolicyTypes.TYPE_VULNERABILITY; import static org.dependencytrack.policy.cel.mapping.FieldMappingUtil.getFieldMappings; @@ -41,6 +42,10 @@ public interface CelPolicyDao { ${fetchColumns?join(", ")} FROM "PROJECT" AS "P" + <#if fetchColumns?filter(col -> col?contains("\\"metadata_tools\\""))?size gt 0> + INNER JOIN + "PROJECT_METADATA" AS "PM" ON "PM"."PROJECT_ID" = "P"."ID" + <#if fetchPropertyColumns?size gt 0> LEFT JOIN LATERAL ( SELECT @@ -154,6 +159,13 @@ default Project loadRequiredFields(final Project project, final MultiValuedMap fieldsToLoad.contains(fieldMapping.protoFieldName())) .map(fieldMapping -> "\"P\".\"%s\" AS \"%s\"".formatted(fieldMapping.sqlColumnName(), fieldMapping.protoFieldName())) .collect(Collectors.toList()); + + if (fieldsToLoad.contains("metadata") + && requirements.containsKey(TYPE_PROJECT_METADATA) + && requirements.get(TYPE_PROJECT_METADATA).contains("tools")) { + sqlSelectColumns.add("\"PM\".\"TOOLS\" AS \"metadata_tools\""); + } + final var sqlPropertySelectColumns = new ArrayList(); if (fieldsToLoad.contains("properties") && requirements.containsKey(TYPE_PROJECT_PROPERTY)) { sqlSelectColumns.add("\"properties\""); diff --git a/src/main/java/org/dependencytrack/policy/cel/persistence/CelPolicyProjectRowMapper.java b/src/main/java/org/dependencytrack/policy/cel/persistence/CelPolicyProjectRowMapper.java index 443465014..5e21eb566 100644 --- a/src/main/java/org/dependencytrack/policy/cel/persistence/CelPolicyProjectRowMapper.java +++ b/src/main/java/org/dependencytrack/policy/cel/persistence/CelPolicyProjectRowMapper.java @@ -3,8 +3,10 @@ import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonToken; import com.google.protobuf.util.JsonFormat; +import org.dependencytrack.model.mapping.PolicyProtoMapper; import org.dependencytrack.persistence.jdbi.mapping.RowMapperUtil; import org.dependencytrack.proto.policy.v1.Project; +import org.dependencytrack.proto.policy.v1.Tools; import org.jdbi.v3.core.mapper.RowMapper; import org.jdbi.v3.core.result.UnableToProduceResultException; import org.jdbi.v3.core.statement.StatementContext; @@ -18,6 +20,7 @@ import static org.apache.commons.lang3.StringUtils.isBlank; import static org.dependencytrack.persistence.jdbi.mapping.RowMapperUtil.OBJECT_MAPPER; +import static org.dependencytrack.persistence.jdbi.mapping.RowMapperUtil.hasColumn; import static org.dependencytrack.persistence.jdbi.mapping.RowMapperUtil.maybeSet; public class CelPolicyProjectRowMapper implements RowMapper { @@ -37,10 +40,44 @@ public Project map(final ResultSet rs, final StatementContext ctx) throws SQLExc maybeSet(rs, "last_bom_import", RowMapperUtil::nullableTimestamp, builder::setLastBomImport); maybeSet(rs, "tags", RowMapperUtil::stringArray, builder::addAllTags); maybeSet(rs, "properties", CelPolicyProjectRowMapper::maybeConvertProperties, builder::addAllProperties); + + if (hasColumn(rs, "metadata_tools")) { + builder.setMetadata(Project.Metadata.newBuilder() + .setTools(convertMetadataTools(rs)) + .build()); + } + return builder.build(); } - private static List maybeConvertProperties(final ResultSet rs, String columnName) throws SQLException { + private static Tools convertMetadataTools(final ResultSet rs) throws SQLException { + final String jsonString = rs.getString("metadata_tools"); + if (isBlank(jsonString)) { + return Tools.getDefaultInstance(); + } + + final org.dependencytrack.model.Tools modelTools; + try { + modelTools = OBJECT_MAPPER.readValue(jsonString, org.dependencytrack.model.Tools.class); + } catch (IOException e) { + throw new UnableToProduceResultException(e); + } + + if (modelTools == null) { + return Tools.getDefaultInstance(); + } + + final var toolsBuilder = Tools.newBuilder(); + if (modelTools.components() != null) { + modelTools.components().stream() + .map(PolicyProtoMapper::mapToProto) + .forEach(toolsBuilder::addComponents); + } + + return toolsBuilder.build(); + } + + private static List maybeConvertProperties(final ResultSet rs, final String columnName) throws SQLException { final String jsonString = rs.getString(columnName); if (isBlank(jsonString)) { return Collections.emptyList(); diff --git a/src/main/java/org/dependencytrack/tasks/BomUploadProcessingTask.java b/src/main/java/org/dependencytrack/tasks/BomUploadProcessingTask.java index 58cf22f36..5133e628b 100644 --- a/src/main/java/org/dependencytrack/tasks/BomUploadProcessingTask.java +++ b/src/main/java/org/dependencytrack/tasks/BomUploadProcessingTask.java @@ -92,6 +92,7 @@ import static org.apache.commons.lang3.StringUtils.trimToNull; import static org.datanucleus.PropertyNames.PROPERTY_FLUSH_MODE; import static org.datanucleus.PropertyNames.PROPERTY_PERSISTENCE_BY_REACHABILITY_AT_COMMIT; +import static org.datanucleus.PropertyNames.PROPERTY_RETAIN_VALUES; import static org.dependencytrack.common.ConfigKey.BOM_UPLOAD_PROCESSING_TRX_FLUSH_THRESHOLD; import static org.dependencytrack.event.kafka.componentmeta.RepoMetaConstants.SUPPORTED_PACKAGE_URLS_FOR_INTEGRITY_CHECK; import static org.dependencytrack.event.kafka.componentmeta.RepoMetaConstants.TIME_SPAN; @@ -220,15 +221,11 @@ private void processBom(final Context ctx, final File bomFile) throws BomConsump // Note: One identity can point to multiple BOM refs, due to component and service de-duplication. final var bomRefsByIdentity = new HashSetValuedHashMap(); - Project metadataComponent = null; - ProjectMetadata projectMetadata = null; + final ProjectMetadata projectMetadata = convertToProjectMetadata(cdxBom.getMetadata()); + final Project project = convertToProject(cdxBom.getMetadata()); List components = new ArrayList<>(); - if (cdxBom.getMetadata() != null) { - projectMetadata = convertToProjectMetadata(cdxBom.getMetadata()); - if (cdxBom.getMetadata().getComponent() != null) { - metadataComponent = convertToProject(cdxBom.getMetadata(), projectMetadata); - components.addAll(convertComponents(cdxBom.getMetadata().getComponent().getComponents())); - } + if (cdxBom.getMetadata() != null && cdxBom.getMetadata().getComponent() != null) { + components.addAll(convertComponents(cdxBom.getMetadata().getComponent().getComponents())); } components.addAll(convertComponents(cdxBom.getComponents())); components = flatten(components, Component::getChildren, Component::setChildren); @@ -304,30 +301,27 @@ private void processBom(final Context ctx, final File bomFile) throws BomConsump // BomUploadProcessingTaskTest#informWithBloatedBomTest can be used to profile the impact on large BOMs. pm.setProperty(PROPERTY_FLUSH_MODE, FlushMode.MANUAL.name()); + // Prevent object fields from being unloaded upon commit. + // + // DataNucleus transitions objects into the "hollow" state after the transaction is committed. + // In hollow state, all fields except the ID are unloaded. Accessing fields afterward triggers + // one or more database queries to load them again. + // See https://www.datanucleus.org/products/accessplatform_6_0/jdo/persistence.html#lifecycle + qm.getPersistenceManager().setProperty(PROPERTY_RETAIN_VALUES, "true"); + LOGGER.info("Processing %d components and %d services from BOM (%s)" .formatted(components.size(), services.size(), ctx)); final Transaction trx = pm.currentTransaction(); try { trx.begin(); - final Project project = processMetadataComponent(ctx, pm, metadataComponent); - if (projectMetadata != null) { - if (project.getMetadata() == null) { - projectMetadata.setProject(project); - qm.getPersistenceManager().makePersistent(projectMetadata); - } else { - project.getMetadata().setSupplier(projectMetadata.getSupplier()); - project.getMetadata().setAuthors(projectMetadata.getAuthors() != null - ? new ArrayList<>(projectMetadata.getAuthors()) - : null); - } - } + final Project persistentProject = processProject(ctx, pm, project, projectMetadata); final Map persistentComponents = - processComponents(qm, project, components, identitiesByBomRef, bomRefsByIdentity); + processComponents(qm, persistentProject, components, identitiesByBomRef, bomRefsByIdentity); final Map persistentServices = - processServices(qm, project, services, identitiesByBomRef, bomRefsByIdentity); - processDependencyGraph(ctx, pm, cdxBom, project, persistentComponents, persistentServices, identitiesByBomRef); - recordBomImport(ctx, pm, project); + processServices(qm, persistentProject, services, identitiesByBomRef, bomRefsByIdentity); + processDependencyGraph(ctx, pm, cdxBom, persistentProject, persistentComponents, persistentServices, identitiesByBomRef); + recordBomImport(ctx, pm, persistentProject); // BOM ref <-> ComponentIdentity indexes are no longer needed. // Let go of their contents to make it eligible for GC sooner. @@ -484,42 +478,58 @@ private static org.cyclonedx.model.Bom parseBom(final Context ctx, final File bo return bom; } - private static Project processMetadataComponent(final Context ctx, final PersistenceManager pm, final Project metadataComponent) throws BomProcessingException { + private static Project processProject(final Context ctx, final PersistenceManager pm, + final Project project, final ProjectMetadata projectMetadata) throws BomProcessingException { final Query query = pm.newQuery(Project.class); query.setFilter("uuid == :uuid"); query.setParameters(ctx.project.getUuid()); - final Project project; + final Project persistentProject; try { - project = query.executeUnique(); + persistentProject = query.executeUnique(); } finally { query.closeAll(); } - if (project == null) { + if (persistentProject == null) { throw new BomProcessingException(ctx, "Project does not exist"); } - if (metadataComponent != null) { - boolean changed = false; - changed |= applyIfChanged(project, metadataComponent, Project::getAuthor, project::setAuthor); - changed |= applyIfChanged(project, metadataComponent, Project::getPublisher, project::setPublisher); - changed |= applyIfChanged(project, metadataComponent, Project::getClassifier, project::setClassifier); - changed |= applyIfChanged(project, metadataComponent, Project::getSupplier, project::setSupplier); - changed |= applyIfChanged(project, metadataComponent, Project::getManufacturer, project::setManufacturer); + boolean hasChanged = false; + if (project != null) { + hasChanged |= applyIfChanged(persistentProject, project, Project::getAuthor, persistentProject::setAuthor); + hasChanged |= applyIfChanged(persistentProject, project, Project::getPublisher, persistentProject::setPublisher); + hasChanged |= applyIfChanged(persistentProject, project, Project::getClassifier, persistentProject::setClassifier); + hasChanged |= applyIfChanged(persistentProject, project, Project::getSupplier, persistentProject::setSupplier); + hasChanged |= applyIfChanged(persistentProject, project, Project::getManufacturer, persistentProject::setManufacturer); // TODO: Currently these properties are "decoupled" from the BOM and managed directly by DT users. // Perhaps there could be a flag for BOM uploads saying "use BOM properties" or something? - // changed |= applyIfChanged(project, metadataComponent, Project::getGroup, project::setGroup); - // changed |= applyIfChanged(project, metadataComponent, Project::getName, project::setName); - // changed |= applyIfChanged(project, metadataComponent, Project::getVersion, project::setVersion); - // changed |= applyIfChanged(project, metadataComponent, Project::getDescription, project::setDescription); - changed |= applyIfChanged(project, metadataComponent, Project::getExternalReferences, project::setExternalReferences); - changed |= applyIfChanged(project, metadataComponent, Project::getPurl, project::setPurl); - changed |= applyIfChanged(project, metadataComponent, Project::getSwidTagId, project::setSwidTagId); - if (changed) { - pm.flush(); + // hasChanged |= applyIfChanged(persistentProject, project, Project::getGroup, persistentProject::setGroup); + // hasChanged |= applyIfChanged(persistentProject, project, Project::getName, persistentProject::setName); + // hasChanged |= applyIfChanged(persistentProject, project, Project::getVersion, persistentProject::setVersion); + // hasChanged |= applyIfChanged(persistentProject, project, Project::getDescription, persistentProject::setDescription); + hasChanged |= applyIfChanged(persistentProject, project, Project::getExternalReferences, persistentProject::setExternalReferences); + hasChanged |= applyIfChanged(persistentProject, project, Project::getPurl, persistentProject::setPurl); + hasChanged |= applyIfChanged(persistentProject, project, Project::getSwidTagId, persistentProject::setSwidTagId); + } + + if (projectMetadata != null) { + if (persistentProject.getMetadata() == null) { + projectMetadata.setProject(persistentProject); + pm.makePersistent(projectMetadata); + hasChanged = true; + } else { + hasChanged |= applyIfChanged(persistentProject.getMetadata(), projectMetadata, ProjectMetadata::getAuthors, + authors -> persistentProject.getMetadata().setAuthors(authors != null ? new ArrayList<>(authors) : null)); + hasChanged |= applyIfChanged(persistentProject.getMetadata(), projectMetadata, ProjectMetadata::getSupplier, persistentProject.getMetadata()::setSupplier); + hasChanged |= applyIfChanged(persistentProject.getMetadata(), projectMetadata, ProjectMetadata::getTools, persistentProject.getMetadata()::setTools); } } - return project; + + if (hasChanged) { + pm.flush(); + } + + return persistentProject; } private static Map processComponents(final QueryManager qm, diff --git a/src/main/proto/org/dependencytrack/policy/v1/policy.proto b/src/main/proto/org/dependencytrack/policy/v1/policy.proto index 545a9b74e..c4fe36ba8 100644 --- a/src/main/proto/org/dependencytrack/policy/v1/policy.proto +++ b/src/main/proto/org/dependencytrack/policy/v1/policy.proto @@ -97,6 +97,11 @@ message Project { optional string purl = 10; optional string swid_tag_id = 11; optional google.protobuf.Timestamp last_bom_import = 12; + optional Metadata metadata = 13; + + message Metadata { + optional Tools tools = 1; + } message Property { string group = 1; @@ -106,6 +111,13 @@ message Project { } } +message Tools { + // Components used as tools. + repeated Component components = 1; + + // TODO: Add services. +} + message Vulnerability { string uuid = 1; string id = 2; diff --git a/src/main/resources/migration/changelog-v5.4.0.xml b/src/main/resources/migration/changelog-v5.4.0.xml new file mode 100644 index 000000000..56e32f357 --- /dev/null +++ b/src/main/resources/migration/changelog-v5.4.0.xml @@ -0,0 +1,16 @@ + + + + + + + + \ No newline at end of file diff --git a/src/test/java/org/dependencytrack/model/mapping/PolicyProtoMapperTest.java b/src/test/java/org/dependencytrack/model/mapping/PolicyProtoMapperTest.java index 05002134f..e048618a0 100644 --- a/src/test/java/org/dependencytrack/model/mapping/PolicyProtoMapperTest.java +++ b/src/test/java/org/dependencytrack/model/mapping/PolicyProtoMapperTest.java @@ -112,7 +112,7 @@ public void testMapVulnerabilityWithNoFieldsSet() throws Exception { @Test public void testMapVulnerabilityToProtoWhenNull() { - assertThat(PolicyProtoMapper.mapToProto(null)) + assertThat(PolicyProtoMapper.mapToProto((Vulnerability) null)) .isEqualTo(org.dependencytrack.proto.policy.v1.Vulnerability.getDefaultInstance()); } diff --git a/src/test/java/org/dependencytrack/persistence/converter/ToolsJsonConverterTest.java b/src/test/java/org/dependencytrack/persistence/converter/ToolsJsonConverterTest.java new file mode 100644 index 000000000..e6d057c42 --- /dev/null +++ b/src/test/java/org/dependencytrack/persistence/converter/ToolsJsonConverterTest.java @@ -0,0 +1,305 @@ +package org.dependencytrack.persistence.converter; + +import org.dependencytrack.model.Classifier; +import org.dependencytrack.model.Component; +import org.dependencytrack.model.DataClassification; +import org.dependencytrack.model.ExternalReference; +import org.dependencytrack.model.OrganizationalEntity; +import org.dependencytrack.model.Project; +import org.dependencytrack.model.ServiceComponent; +import org.dependencytrack.model.Tools; +import org.dependencytrack.model.Vulnerability; +import org.junit.Test; + +import java.util.List; +import java.util.UUID; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; + +public class ToolsJsonConverterTest { + + @Test + public void testConvertToDatastore() { + final var project = new Project(); + project.setName("acme-app"); + + final var componentSupplier = new OrganizationalEntity(); + componentSupplier.setName("componentSupplierName"); + + final var externalReference = new ExternalReference(); + externalReference.setType(org.cyclonedx.model.ExternalReference.Type.DOCUMENTATION); + externalReference.setUrl("https://example.com"); + + final var vuln = new Vulnerability(); + vuln.setVulnId("INT-001"); + vuln.setSource(Vulnerability.Source.INTERNAL); + + final var component = new Component(); + component.setProject(project); + component.setId(123); + component.setUuid(UUID.randomUUID()); + component.setAuthor("componentAuthor"); + component.setPublisher("componentPublisher"); + component.setSupplier(componentSupplier); + component.setGroup("componentGroup"); + component.setName("componentName"); + component.setVersion("componentVersion"); + component.setClassifier(Classifier.LIBRARY); + component.setFilename("componentFilename"); + component.setExtension("componentExtension"); + component.setMd5("componentMd5"); + component.setSha1("componentSha1"); + component.setSha256("componentSha256"); + component.setSha384("componentSha384"); + component.setSha512("componentSha512"); + component.setSha3_256("componentSha3_256"); + component.setSha3_384("componentSha3_384"); + component.setSha3_512("componentSha3_512"); + component.setBlake2b_256("componentBlake2b_256"); + component.setBlake2b_384("componentBlake2b_384"); + component.setBlake2b_512("componentBlake2b_512"); + component.setBlake3("componentBlake3"); + component.setCpe("componentCpe"); + component.setPurl("pkg:maven/componentGroup/componentName@componentVersion?foo=bar"); + component.setPurlCoordinates("pkg:maven/componentGroup/componentName@componentVersion"); + component.setSwidTagId("componentSwidTagId"); + component.setInternal(true); + component.setDescription("componentDescription"); + component.setCopyright("componentCopyright"); + component.setLicense("componentLicense"); + component.setLicenseExpression("componentLicenseExpression"); + component.setLicenseUrl("componentLicenseUrl"); + component.setDirectDependencies("componentDirectDependencies"); + component.setExternalReferences(List.of(externalReference)); + component.setParent(component); + component.setChildren(List.of(component)); + component.setVulnerabilities(List.of(vuln)); + component.setLastInheritedRiskScore(10.0); + component.setNotes("componentNotes"); + + final var serviceProvider = new OrganizationalEntity(); + serviceProvider.setName("serviceProviderName"); + + final var serviceDataClassification = new DataClassification(); + serviceDataClassification.setDirection(DataClassification.Direction.OUTBOUND); + serviceDataClassification.setName("serviceDataClassificationName"); + + final var service = new ServiceComponent(); + service.setProject(project); + service.setId(123); + service.setUuid(UUID.randomUUID()); + service.setProvider(serviceProvider); + service.setGroup("serviceGroup"); + service.setName("serviceName"); + service.setVersion("serviceVersion"); + service.setDescription("serviceDescription"); + service.setEndpoints(new String[]{"https://example.com"}); + service.setAuthenticated(true); + service.setCrossesTrustBoundary(true); + service.setData(List.of(serviceDataClassification)); + service.setExternalReferences(List.of(externalReference)); + service.setParent(service); + service.setChildren(List.of(service)); + service.setVulnerabilities(List.of(vuln)); + service.setLastInheritedRiskScore(11.0); + service.setNotes("serviceNotes"); + + assertThatJson(new ToolsJsonConverter().convertToDatastore(new Tools(List.of(component), List.of(service)))) + .isEqualTo(""" + { + "components": [ + { + "author": "componentAuthor", + "blake2b_256": "componentBlake2b_256", + "blake2b_384": "componentBlake2b_384", + "blake2b_512": "componentBlake2b_512", + "blake3": "componentBlake3", + "classifier": "LIBRARY", + "cpe": "componentCpe", + "externalReferences": [ + { + "type": "documentation", + "url": "https://example.com" + } + ], + "group": "componentGroup", + "md5": "componentmd5", + "name": "componentName", + "publisher": "componentPublisher", + "purl": "pkg:maven/componentGroup/componentName@componentVersion?foo=bar", + "sha1": "componentsha1", + "sha256": "componentsha256", + "sha384": "componentsha384", + "sha3_256": "componentsha3_256", + "sha3_384": "componentsha3_384", + "sha3_512": "componentsha3_512", + "sha512": "componentsha512", + "supplier": { + "name": "componentSupplierName" + }, + "swidTagId": "componentSwidTagId", + "version": "componentVersion" + } + ], + "services": [ + { + "provider": { + "name": "serviceProviderName" + }, + "group": "serviceGroup", + "name": "serviceName", + "version": "serviceVersion", + "description": "serviceDescription", + "endpoints": [ + "https://example.com" + ], + "authenticated": true, + "crossesTrustBoundary": true, + "data": [ + { + "direction": "OUTBOUND", + "name": "serviceDataClassificationName" + } + ], + "externalReferences": [ + { + "type": "documentation", + "url": "https://example.com" + } + ] + } + ] + } + """); + } + + @Test + public void testConvertToAttribute() { + final Tools tools = new ToolsJsonConverter().convertToAttribute(""" + { + "components": [ + { + "author": "componentAuthor", + "blake2b_256": "componentBlake2b_256", + "blake2b_384": "componentBlake2b_384", + "blake2b_512": "componentBlake2b_512", + "blake3": "componentBlake3", + "classifier": "LIBRARY", + "cpe": "componentCpe", + "externalReferences": [ + { + "type": "documentation", + "url": "https://example.com" + } + ], + "group": "componentGroup", + "md5": "componentmd5", + "name": "componentName", + "publisher": "componentPublisher", + "purl": "pkg:maven/componentGroup/componentName@componentVersion?foo=bar", + "sha1": "componentsha1", + "sha256": "componentsha256", + "sha384": "componentsha384", + "sha3_256": "componentsha3_256", + "sha3_384": "componentsha3_384", + "sha3_512": "componentsha3_512", + "sha512": "componentsha512", + "supplier": { + "name": "componentSupplierName" + }, + "swidTagId": "componentSwidTagId", + "version": "componentVersion" + } + ], + "services": [ + { + "provider": { + "name": "serviceProviderName" + }, + "group": "serviceGroup", + "name": "serviceName", + "version": "serviceVersion", + "description": "serviceDescription", + "endpoints": [ + "https://example.com" + ], + "authenticated": true, + "crossesTrustBoundary": true, + "data": [ + { + "direction": "OUTBOUND", + "name": "serviceDataClassificationName" + } + ], + "externalReferences": [ + { + "type": "documentation", + "url": "https://example.com" + } + ] + } + ] + } + """); + + assertThat(tools).isNotNull(); + assertThat(tools.components()).satisfiesExactly(component -> { + assertThat(component.getAuthor()).isEqualTo("componentAuthor"); + assertThat(component.getBlake2b_256()).isEqualTo("componentBlake2b_256"); + assertThat(component.getBlake2b_384()).isEqualTo("componentBlake2b_384"); + assertThat(component.getBlake2b_512()).isEqualTo("componentBlake2b_512"); + assertThat(component.getBlake3()).isEqualTo("componentBlake3"); + assertThat(component.getClassifier()).isEqualTo(Classifier.LIBRARY); + assertThat(component.getCpe()).isEqualTo("componentCpe"); + assertThat(component.getExternalReferences()).satisfiesExactly(externalReference -> { + assertThat(externalReference.getType()).isEqualTo(org.cyclonedx.model.ExternalReference.Type.DOCUMENTATION); + assertThat(externalReference.getUrl()).isEqualTo("https://example.com"); + }); + assertThat(component.getGroup()).isEqualTo("componentGroup"); + assertThat(component.getMd5()).isEqualTo("componentmd5"); + assertThat(component.getName()).isEqualTo("componentName"); + assertThat(component.getPublisher()).isEqualTo("componentPublisher"); + assertThat(component.getPurl()).asString().isEqualTo("pkg:maven/componentGroup/componentName@componentVersion?foo=bar"); + assertThat(component.getSha1()).isEqualTo("componentsha1"); + assertThat(component.getSha256()).isEqualTo("componentsha256"); + assertThat(component.getSha384()).isEqualTo("componentsha384"); + assertThat(component.getSha512()).isEqualTo("componentsha512"); + assertThat(component.getSha3_256()).isEqualTo("componentsha3_256"); + assertThat(component.getSha3_384()).isEqualTo("componentsha3_384"); + assertThat(component.getSha3_512()).isEqualTo("componentsha3_512"); + assertThat(component.getSupplier()).satisfies(supplier -> assertThat(supplier.getName()).isEqualTo("componentSupplierName")); + assertThat(component.getSwidTagId()).isEqualTo("componentSwidTagId"); + assertThat(component.getVersion()).isEqualTo("componentVersion"); + }); + assertThat(tools.services()).satisfiesExactly(service -> { + assertThat(service.getProvider()).satisfies(provider -> assertThat(provider.getName()).isEqualTo("serviceProviderName")); + assertThat(service.getGroup()).isEqualTo("serviceGroup"); + assertThat(service.getName()).isEqualTo("serviceName"); + assertThat(service.getVersion()).isEqualTo("serviceVersion"); + assertThat(service.getDescription()).isEqualTo("serviceDescription"); + assertThat(service.getEndpoints()).containsOnly("https://example.com"); + assertThat(service.getAuthenticated()).isTrue(); + assertThat(service.getCrossesTrustBoundary()).isTrue(); + assertThat(service.getData()).satisfiesExactly(classification -> { + assertThat(classification.getDirection()).isEqualTo(DataClassification.Direction.OUTBOUND); + assertThat(classification.getName()).isEqualTo("serviceDataClassificationName"); + }); + assertThat(service.getExternalReferences()).satisfiesExactly(externalReference -> { + assertThat(externalReference.getType()).isEqualTo(org.cyclonedx.model.ExternalReference.Type.DOCUMENTATION); + assertThat(externalReference.getUrl()).isEqualTo("https://example.com"); + }); + }); + } + + @Test + public void testConvertToDatastoreNull() { + assertThat(new ToolsJsonConverter().convertToDatastore(null)).isNull(); + } + + @Test + public void testConvertToAttributeNull() { + assertThat(new ToolsJsonConverter().convertToAttribute(null)).isNull(); + } + +} \ No newline at end of file diff --git a/src/test/java/org/dependencytrack/policy/cel/CelPolicyEngineTest.java b/src/test/java/org/dependencytrack/policy/cel/CelPolicyEngineTest.java index 9469a9468..a4e216d4f 100644 --- a/src/test/java/org/dependencytrack/policy/cel/CelPolicyEngineTest.java +++ b/src/test/java/org/dependencytrack/policy/cel/CelPolicyEngineTest.java @@ -18,10 +18,12 @@ import org.dependencytrack.model.PolicyCondition; import org.dependencytrack.model.PolicyViolation; import org.dependencytrack.model.Project; +import org.dependencytrack.model.ProjectMetadata; import org.dependencytrack.model.RepositoryMetaComponent; import org.dependencytrack.model.RepositoryType; import org.dependencytrack.model.Severity; import org.dependencytrack.model.Tag; +import org.dependencytrack.model.Tools; import org.dependencytrack.model.ViolationAnalysisState; import org.dependencytrack.model.Vulnerability; import org.dependencytrack.model.VulnerabilityAlias; @@ -69,7 +71,7 @@ public void before() throws Exception { * Data being available means: *
    *
  • Expression requirements were analyzed correctly
  • - *
  • Database was retrieved from the database correctly
  • + *
  • Data was retrieved from the database correctly
  • *
  • The mapping from DB data to CEL Protobuf models worked as expected
  • *
*/ @@ -88,6 +90,43 @@ public void testEvaluateProjectWithAllFields() { project.setLastBomImport(new java.util.Date()); qm.persist(project); + final var toolComponentLicense = new License(); + toolComponentLicense.setUuid(UUID.randomUUID()); + toolComponentLicense.setLicenseId("toolComponentLicenseId"); + + final var toolComponent = new Component(); + toolComponent.setGroup("toolComponentGroup"); + toolComponent.setName("toolComponentName"); + toolComponent.setVersion("toolComponentVersion"); + toolComponent.setClassifier(Classifier.APPLICATION); + toolComponent.setCpe("toolComponentCpe"); + toolComponent.setPurl("pkg:maven/toolComponentGroup/toolComponentName@toolComponentVersion"); // NB: Must be valid PURL, otherwise it's being JSON serialized as null + toolComponent.setSwidTagId("toolComponentSwidTagId"); + toolComponent.setInternal(true); // NB: Currently ignored for tool components. + toolComponent.setMd5("toolComponentMd5"); + toolComponent.setSha1("toolComponentSha1"); + toolComponent.setSha256("toolComponentSha256"); + toolComponent.setSha384("toolComponentSha384"); + toolComponent.setSha512("toolComponentSha512"); + toolComponent.setSha3_256("toolComponentSha3_256"); + toolComponent.setSha3_384("toolComponentSha3_384"); + toolComponent.setSha3_512("toolComponentSha3_512"); + toolComponent.setBlake2b_256("toolComponentBlake2b_256"); + toolComponent.setBlake2b_384("toolComponentBlake2b_384"); + toolComponent.setBlake2b_512("toolComponentBlake2b_512"); + toolComponent.setBlake3("toolComponentBlake3"); + // NB: License data is currently ignored for tool components. + // Including it in the test for documentation purposes. + toolComponent.setLicense("toolComponentLicense"); + toolComponent.setLicenseExpression("toolComponentLicenseExpression"); + toolComponent.setLicenseUrl("toolComponentLicenseUrl"); + toolComponent.setResolvedLicense(toolComponentLicense); + + final var projectMetadata = new ProjectMetadata(); + projectMetadata.setProject(project); + projectMetadata.setTools(new Tools(List.of(toolComponent), null)); + qm.persist(projectMetadata); + qm.createProjectProperty(project, "propertyGroup", "propertyName", "propertyValue", IConfigProperty.PropertyType.STRING, null); qm.bind(project, List.of( @@ -231,6 +270,31 @@ public void testEvaluateProjectWithAllFields() { && project.purl == "projectPurl" && project.swid_tag_id == "projectSwidTagId" && has(project.last_bom_import) + && project.metadata.tools.components.all(tool, + tool.group == "toolComponentGroup" + && tool.name == "toolComponentName" + && tool.version == "toolComponentVersion" + && tool.classifier == "APPLICATION" + && tool.cpe == "toolComponentCpe" + && tool.purl == "pkg:maven/toolComponentGroup/toolComponentName@toolComponentVersion" + && tool.swid_tag_id == "toolComponentSwidTagId" + && !tool.is_internal + && tool.md5 == "toolcomponentmd5" + && tool.sha1 == "toolcomponentsha1" + && tool.sha256 == "toolcomponentsha256" + && tool.sha384 == "toolcomponentsha384" + && tool.sha512 == "toolcomponentsha512" + && tool.sha3_256 == "toolcomponentsha3_256" + && tool.sha3_384 == "toolcomponentsha3_384" + && tool.sha3_512 == "toolcomponentsha3_512" + && tool.blake2b_256 == "toolComponentBlake2b_256" + && tool.blake2b_384 == "toolComponentBlake2b_384" + && tool.blake2b_512 == "toolComponentBlake2b_512" + && tool.blake3 == "toolComponentBlake3" + && !has(tool.license_name) + && !has(tool.license_expression) + && !has(tool.resolved_license) + ) && "projecttaga" in project.tags && project.properties.all(property, property.group == "propertyGroup" @@ -1582,7 +1646,7 @@ public void testEvaluateProjectWithFuncMatchesRangeWithInvalidRange() { qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ project.matches_range("foo") && component.matches_range("bar") - """); + """, PolicyViolation.Type.OPERATIONAL); final var project = new Project(); project.setName("acme-app"); @@ -1606,6 +1670,45 @@ public void testEvaluateProjectWithFuncMatchesRangeWithInvalidRange() { assertThat(qm.getAllPolicyViolations(componentB)).isEmpty(); } + @Test + public void testEvaluateProjectWithToolMetadata() { + final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL); + qm.createPolicyCondition(policy, PolicyCondition.Subject.EXPRESSION, PolicyCondition.Operator.MATCHES, """ + project.metadata.tools.components.exists(tool, + tool.name == "toolName" && tool.matches_range("vers:generic/>=1.2.3|<3")) + """, PolicyViolation.Type.OPERATIONAL); + + final var project = new Project(); + project.setName("acme-app"); + project.setVersion("0.1"); + qm.persist(project); + + final var toolComponent = new Component(); + toolComponent.setName("toolName"); + toolComponent.setVersion("2.3.1"); + + final var projectMetadata = new ProjectMetadata(); + projectMetadata.setProject(project); + projectMetadata.setTools(new Tools(List.of(toolComponent), null)); + qm.persist(projectMetadata); + + final var componentA = new Component(); + componentA.setProject(project); + componentA.setName("acme-lib-a"); + componentA.setVersion("v1.9.3"); + qm.persist(componentA); + + assertThatNoException().isThrownBy(() -> new CelPolicyEngine().evaluateProject(project.getUuid())); + assertThat(qm.getAllPolicyViolations(componentA)).hasSize(1); + + toolComponent.setVersion("3.1"); + projectMetadata.setTools(new Tools(List.of(toolComponent), null)); + qm.persist(projectMetadata); + + assertThatNoException().isThrownBy(() -> new CelPolicyEngine().evaluateProject(project.getUuid())); + assertThat(qm.getAllPolicyViolations(componentA)).isEmpty(); + } + @Test public void testEvaluateProjectWhenProjectDoesNotExist() { assertThatNoException().isThrownBy(() -> new CelPolicyEngine().evaluateProject(UUID.randomUUID())); diff --git a/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java b/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java index 86c1c8cd3..876ef1503 100644 --- a/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java +++ b/src/test/java/org/dependencytrack/tasks/BomUploadProcessingTaskTest.java @@ -907,6 +907,63 @@ public void informWithBomContainingServiceTest() throws Exception { assertThat(qm.getAllServiceComponents(project)).isNotEmpty(); } + @Test + public void informWithBomContainingMetadataToolsDeprecatedTest() throws Exception { + final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, true, false); + + final var bomUploadEvent = new BomUploadEvent(qm.detach(Project.class, project.getId()), createTempBomFile("bom-metadata-tool-deprecated.json")); + qm.createWorkflowSteps(bomUploadEvent.getChainIdentifier()); + new BomUploadProcessingTask().inform(bomUploadEvent); + assertBomProcessedNotification(); + + qm.getPersistenceManager().refresh(project); + assertThat(project.getMetadata()).isNotNull(); + assertThat(project.getMetadata().getTools()).isNotNull(); + assertThat(project.getMetadata().getTools().components()).satisfiesExactly(component -> { + assertThat(component.getSupplier()).isNotNull(); + assertThat(component.getSupplier().getName()).isEqualTo("Awesome Vendor"); + assertThat(component.getName()).isEqualTo("Awesome Tool"); + assertThat(component.getVersion()).isEqualTo("9.1.2"); + assertThat(component.getSha1()).isEqualTo("25ed8e31b995bb927966616df2a42b979a2717f0"); + assertThat(component.getSha256()).isEqualTo("a74f733635a19aefb1f73e5947cef59cd7440c6952ef0f03d09d974274cbd6df"); + }); + assertThat(project.getMetadata().getTools().services()).isNull(); + } + + @Test + public void informWithBomContainingMetadataToolsTest() throws Exception { + final Project project = qm.createProject("Acme Example", null, "1.0", null, null, null, true, false); + + final var bomUploadEvent = new BomUploadEvent(qm.detach(Project.class, project.getId()), createTempBomFile("bom-metadata-tool.json")); + qm.createWorkflowSteps(bomUploadEvent.getChainIdentifier()); + new BomUploadProcessingTask().inform(bomUploadEvent); + assertBomProcessedNotification(); + + qm.getPersistenceManager().refresh(project); + assertThat(project.getMetadata()).isNotNull(); + assertThat(project.getMetadata().getTools()).isNotNull(); + assertThat(project.getMetadata().getTools().components()).satisfiesExactly(component -> { + assertThat(component.getGroup()).isEqualTo("Awesome Vendor"); + assertThat(component.getName()).isEqualTo("Awesome Tool"); + assertThat(component.getVersion()).isEqualTo("9.1.2"); + assertThat(component.getSha1()).isEqualTo("25ed8e31b995bb927966616df2a42b979a2717f0"); + assertThat(component.getSha256()).isEqualTo("a74f733635a19aefb1f73e5947cef59cd7440c6952ef0f03d09d974274cbd6df"); + }); + assertThat(project.getMetadata().getTools().services()).satisfiesExactly(service -> { + assertThat(service.getProvider()).isNotNull(); + assertThat(service.getProvider().getName()).isEqualTo("Acme Org"); + assertThat(service.getProvider().getUrls()).containsOnly("https://example.com"); + assertThat(service.getGroup()).isEqualTo("com.example"); + assertThat(service.getName()).isEqualTo("Acme Signing Server"); + assertThat(service.getDescription()).isEqualTo("Signs artifacts"); + assertThat(service.getEndpoints()).containsExactlyInAnyOrder( + "https://example.com/sign", + "https://example.com/verify", + "https://example.com/tsa" + ); + }); + } + private void assertBomProcessedNotification() throws Exception { try { assertThat(kafkaMockProducer.history()).anySatisfy(record -> { diff --git a/src/test/resources/unit/bom-metadata-tool-deprecated.json b/src/test/resources/unit/bom-metadata-tool-deprecated.json new file mode 100644 index 000000000..7e578d7f5 --- /dev/null +++ b/src/test/resources/unit/bom-metadata-tool-deprecated.json @@ -0,0 +1,26 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "tools": [ + { + "vendor": "Awesome Vendor", + "name": "Awesome Tool", + "version": "9.1.2", + "hashes": [ + { + "alg": "SHA-1", + "content": "25ed8e31b995bb927966616df2a42b979a2717f0" + }, + { + "alg": "SHA-256", + "content": "a74f733635a19aefb1f73e5947cef59cd7440c6952ef0f03d09d974274cbd6df" + } + ] + } + ] + }, + "components": [] +} \ No newline at end of file diff --git a/src/test/resources/unit/bom-metadata-tool.json b/src/test/resources/unit/bom-metadata-tool.json new file mode 100644 index 000000000..aa55d6765 --- /dev/null +++ b/src/test/resources/unit/bom-metadata-tool.json @@ -0,0 +1,47 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "tools": { + "components": [ + { + "type": "application", + "group": "Awesome Vendor", + "name": "Awesome Tool", + "version": "9.1.2", + "hashes": [ + { + "alg": "SHA-1", + "content": "25ed8e31b995bb927966616df2a42b979a2717f0" + }, + { + "alg": "SHA-256", + "content": "a74f733635a19aefb1f73e5947cef59cd7440c6952ef0f03d09d974274cbd6df" + } + ] + } + ], + "services": [ + { + "provider": { + "name": "Acme Org", + "url": [ + "https://example.com" + ] + }, + "group": "com.example", + "name": "Acme Signing Server", + "description": "Signs artifacts", + "endpoints": [ + "https://example.com/sign", + "https://example.com/verify", + "https://example.com/tsa" + ] + } + ] + } + }, + "components": [] +} \ No newline at end of file