Repository: CycloneDX/cyclonedx-core-java
Pull request #324 (2023-09-05 22:17:05)

Fix failure to deserialize `externalReferences` from XML
Deserialization would previously fail with the following exception:

```
org.cyclonedx.exception.ParseException: com.fasterxml.jackson.databind.JsonMappingException: Cannot deserialize value of type `java.util.ArrayList<org.cyclonedx.model.ExternalReference>` from Object value (token `JsonToken.START_OBJECT`)
 at [Source: UNKNOWN; byte offset: #UNKNOWN] (through reference chain: org.cyclonedx.model.Component["externalReferences"]) (through reference chain: org.cyclonedx.model.Bom["metadata"])
```

Thanks to @mr-zepol for nudging me into the right direction.


Patch:
diff --git a/src/main/java/org/cyclonedx/model/Bom.java b/src/main/java/org/cyclonedx/model/Bom.java
index 3f071370f..9fba6c692 100644
--- a/src/main/java/org/cyclonedx/model/Bom.java
+++ b/src/main/java/org/cyclonedx/model/Bom.java
@@ -32,6 +32,7 @@ import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
 import org.cyclonedx.model.formulation.Formula;
 import org.cyclonedx.model.vulnerability.Vulnerability;
 import org.cyclonedx.util.deserializer.DependencyDeserializer;
+import org.cyclonedx.util.deserializer.ExternalReferencesDeserializer;
 import org.cyclonedx.util.deserializer.VulnerabilityDeserializer;
 
 @SuppressWarnings("unused")
@@ -73,6 +74,7 @@ public class Bom extends ExtensibleElement {
     private DependencyList dependencies;
 
     @VersionFilter(versions = {"1.0"})
+    @JsonDeserialize(using = ExternalReferencesDeserializer.class)
     private List<ExternalReference> externalReferences;
 
     @VersionFilter(versions = {"1.0", "1.1", "1.2"})
diff --git a/src/main/java/org/cyclonedx/model/Component.java b/src/main/java/org/cyclonedx/model/Component.java
index f0ae71934..2fe1af970 100644
--- a/src/main/java/org/cyclonedx/model/Component.java
+++ b/src/main/java/org/cyclonedx/model/Component.java
@@ -24,6 +24,7 @@ import java.util.Objects;
 
 import org.cyclonedx.model.component.ModelCard;
 import org.cyclonedx.model.component.modelCard.ComponentData;
+import org.cyclonedx.util.deserializer.ExternalReferencesDeserializer;
 import org.cyclonedx.util.deserializer.HashesDeserializer;
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 import com.fasterxml.jackson.annotation.JsonInclude;
@@ -345,6 +346,7 @@ public class Component extends ExtensibleElement {
 
     @JacksonXmlElementWrapper(localName = "externalReferences")
     @JacksonXmlProperty(localName = "reference")
+    @JsonDeserialize(using = ExternalReferencesDeserializer.class)
     public List<ExternalReference> getExternalReferences() {
         return externalReferences;
     }
diff --git a/src/main/java/org/cyclonedx/model/ExternalReference.java b/src/main/java/org/cyclonedx/model/ExternalReference.java
index c3e8b11ec..684e0688d 100644
--- a/src/main/java/org/cyclonedx/model/ExternalReference.java
+++ b/src/main/java/org/cyclonedx/model/ExternalReference.java
@@ -124,6 +124,15 @@ public class ExternalReference {
         Type(String name) {
             this.name = name;
         }
+
+        public static Type fromString(String text) {
+            for (Type t : Type.values()) {
+                if (t.name.equals(text)) {
+                    return t;
+                }
+            }
+            return null;
+        }
     }
 
     private String url;
diff --git a/src/main/java/org/cyclonedx/model/Service.java b/src/main/java/org/cyclonedx/model/Service.java
index 42f5c46ab..90d0d58b2 100644
--- a/src/main/java/org/cyclonedx/model/Service.java
+++ b/src/main/java/org/cyclonedx/model/Service.java
@@ -25,6 +25,7 @@ import com.fasterxml.jackson.annotation.JsonPropertyOrder;
 import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
 import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
 import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import org.cyclonedx.util.deserializer.ExternalReferencesDeserializer;
 import org.cyclonedx.util.deserializer.StringListDeserializer;
 
 import java.util.ArrayList;
@@ -187,6 +188,7 @@ public class Service extends ExtensibleElement {
 
     @JacksonXmlElementWrapper(localName = "externalReferences")
     @JacksonXmlProperty(localName = "reference")
+    @JsonDeserialize(using = ExternalReferencesDeserializer.class)
     public List<ExternalReference> getExternalReferences() {
         return externalReferences;
     }
diff --git a/src/main/java/org/cyclonedx/model/Tool.java b/src/main/java/org/cyclonedx/model/Tool.java
index 1e4373549..c0506899e 100644
--- a/src/main/java/org/cyclonedx/model/Tool.java
+++ b/src/main/java/org/cyclonedx/model/Tool.java
@@ -26,6 +26,7 @@ import com.fasterxml.jackson.annotation.JsonPropertyOrder;
 import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
 import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
 import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
+import org.cyclonedx.util.deserializer.ExternalReferencesDeserializer;
 import org.cyclonedx.util.deserializer.HashesDeserializer;
 
 @JsonIgnoreProperties(ignoreUnknown = true)
@@ -77,6 +78,7 @@ public class Tool extends ExtensibleElement {
 
     @JacksonXmlElementWrapper(localName = "externalReferences")
     @JacksonXmlProperty(localName = "reference")
+    @JsonDeserialize(using = ExternalReferencesDeserializer.class)
     public List<ExternalReference> getExternalReferences() {
         return externalReferences;
     }
diff --git a/src/main/java/org/cyclonedx/util/deserializer/ExternalReferencesDeserializer.java b/src/main/java/org/cyclonedx/util/deserializer/ExternalReferencesDeserializer.java
new file mode 100644
index 000000000..68f42f3f1
--- /dev/null
+++ b/src/main/java/org/cyclonedx/util/deserializer/ExternalReferencesDeserializer.java
@@ -0,0 +1,75 @@
+/*
+ * This file is part of CycloneDX Core (Java).
+ *
+ * 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.cyclonedx.util.deserializer;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import org.cyclonedx.model.ExternalReference;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+public class ExternalReferencesDeserializer extends JsonDeserializer<List<ExternalReference>> {
+
+    private final HashesDeserializer hashesDeserializer = new HashesDeserializer();
+
+    @Override
+    public List<ExternalReference> deserialize(JsonParser parser, DeserializationContext context) throws IOException {
+        JsonNode node = parser.getCodec().readTree(parser);
+        if (node.has("reference")) {
+            return parseExternalReferences(node.get("reference"), parser, context);
+        } else {
+            return parseExternalReferences(node, parser, context);
+        }
+    }
+
+    private List<ExternalReference> parseExternalReferences(JsonNode node, JsonParser p, DeserializationContext ctxt) throws IOException {
+        List<ExternalReference> references = new ArrayList<>();
+        ArrayNode nodes = (node.isArray() ? (ArrayNode) node : new ArrayNode(null).add(node));
+        for (JsonNode resolvesNode : nodes) {
+            ExternalReference type = parseExternalReference(resolvesNode, p, ctxt);
+            references.add(type);
+        }
+        return references;
+    }
+
+    private ExternalReference parseExternalReference(JsonNode node, JsonParser p, DeserializationContext ctxt) throws IOException {
+        ExternalReference reference = new ExternalReference();
+        if (node.has("url")) {
+            reference.setUrl(node.get("url").asText());
+        }
+        if (node.has("type")) {
+            reference.setType(ExternalReference.Type.fromString(node.get("type").asText()));
+        }
+        if (node.has("comment")) {
+            reference.setComment(node.get("comment").asText());
+        }
+        if (node.has("hashes")) {
+            JsonParser hashesParser = node.get("hashes").traverse(p.getCodec());
+            hashesParser.nextToken();
+            reference.setHashes(hashesDeserializer.deserialize(hashesParser, ctxt));
+        }
+        return reference;
+    }
+
+}


Test patch:
diff --git a/src/test/resources/1.5/valid-external-reference-1.5.json b/src/test/resources/1.5/valid-external-reference-1.5.json
index 78a3eb63f..fc3c94940 100644
--- a/src/test/resources/1.5/valid-external-reference-1.5.json
+++ b/src/test/resources/1.5/valid-external-reference-1.5.json
@@ -3,6 +3,26 @@
   "specVersion": "1.5",
   "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
   "version": 1,
+  "metadata": {
+    "component": {
+      "type": "application",
+      "name": "myapp",
+      "version": "1.0.0",
+      "externalReferences": [
+        {
+          "type": "bom",
+          "url": "https://example.org/support/sbom/portal-server/1.0.0",
+          "comment": "An external SBOM that describes what this component includes",
+          "hashes": [
+            {
+              "alg": "SHA-256",
+              "content": "708f1f53b41f11f02d12a11b1a38d2905d47b099afc71a0f1124ef8582ec7313"
+            }
+          ]
+        }
+      ]
+    }
+  },
   "components": [
     {
       "type": "library",
@@ -34,5 +54,18 @@
         }
       ]
     }
+  ],
+  "externalReferences": [
+    {
+      "type": "bom",
+      "url": "https://example.org/support/external-file",
+      "comment": "An external file that is distributed alongside this BOM",
+      "hashes": [
+        {
+          "alg": "SHA-256",
+          "content": "708f1f53b41f11f02d12a11b1a38d2905d47b099afc71a0f1124ef8582ec7313"
+        }
+      ]
+    }
   ]
 }
diff --git a/src/test/resources/1.5/valid-external-reference-1.5.xml b/src/test/resources/1.5/valid-external-reference-1.5.xml
index 0599884bb..f3cf3cff5 100644
--- a/src/test/resources/1.5/valid-external-reference-1.5.xml
+++ b/src/test/resources/1.5/valid-external-reference-1.5.xml
@@ -1,5 +1,20 @@
 <?xml version="1.0"?>
 <bom serialNumber="urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79" version="1" xmlns="http://cyclonedx.org/schema/bom/1.5">
+    <metadata>
+        <component type="application">
+            <name>myapp</name>
+            <version>1.0.0</version>
+            <externalReferences>
+                <reference type="bom">
+                    <url>https://example.org/support/sbom/portal-server/1.0.0</url>
+                    <comment>An external SBOM that describes what this component includes</comment>
+                    <hashes>
+                        <hash alg="SHA-256">f498a8ff2dd007e29c2074f5e4b01a9a01775c3ff3aeaf6906ea503bc5791b7b</hash>
+                    </hashes>
+                </reference>
+            </externalReferences>
+        </component>
+    </metadata>
     <components>
         <component type="library">
             <group>org.example</group>
@@ -24,4 +39,13 @@
             </externalReferences>
         </component>
     </components>
+    <externalReferences>
+        <reference type="bom">
+            <url>https://example.org/support/external-file</url>
+            <comment>An external file that is distributed alongside this BOM</comment>
+            <hashes>
+                <hash alg="SHA-256">f498a8ff2dd007e29c2074f5e4b01a9a01775c3ff3aeaf6906ea503bc5791b7b</hash>
+            </hashes>
+        </reference>
+    </externalReferences>
 </bom>
diff --git a/src/test/resources/1.5/valid-metadata-tool-deprecated-1.5.json b/src/test/resources/1.5/valid-metadata-tool-deprecated-1.5.json
index 81e908a98..b692d9238 100644
--- a/src/test/resources/1.5/valid-metadata-tool-deprecated-1.5.json
+++ b/src/test/resources/1.5/valid-metadata-tool-deprecated-1.5.json
@@ -18,6 +18,19 @@
             "alg": "SHA-256",
             "content": "a74f733635a19aefb1f73e5947cef59cd7440c6952ef0f03d09d974274cbd6df"
           }
+        ],
+        "externalReferences": [
+          {
+            "type": "bom",
+            "url": "https://example.org/support/sbom/awesome-tool/9.1.2",
+            "comment": "An external SBOM that describes what this tool includes",
+            "hashes": [
+              {
+                "alg": "SHA-256",
+                "content": "708f1f53b41f11f02d12a11b1a38d2905d47b099afc71a0f1124ef8582ec7313"
+              }
+            ]
+          }
         ]
       }
     ]
diff --git a/src/test/resources/1.5/valid-metadata-tool-deprecated-1.5.xml b/src/test/resources/1.5/valid-metadata-tool-deprecated-1.5.xml
index caf273f6c..d3140e4f2 100644
--- a/src/test/resources/1.5/valid-metadata-tool-deprecated-1.5.xml
+++ b/src/test/resources/1.5/valid-metadata-tool-deprecated-1.5.xml
@@ -10,6 +10,15 @@
                     <hash alg="SHA-1">25ed8e31b995bb927966616df2a42b979a2717f0</hash>
                     <hash alg="SHA-256">a74f733635a19aefb1f73e5947cef59cd7440c6952ef0f03d09d974274cbd6df</hash>
                 </hashes>
+                <externalReferences>
+                    <reference type="bom">
+                        <url>https://example.org/support/sbom/awesome-tool/9.1.2</url>
+                        <comment>An external SBOM that describes what this tool includes</comment>
+                        <hashes>
+                            <hash alg="SHA-256">f498a8ff2dd007e29c2074f5e4b01a9a01775c3ff3aeaf6906ea503bc5791b7b</hash>
+                        </hashes>
+                    </reference>
+                </externalReferences>
             </tool>
         </tools>
     </metadata>