Repository: CycloneDX/cyclonedx-maven-plugin
Pull request #431 (2023-11-16 13:32:26)

Support metadata when dependency is any other dependency type than jar
The plugin is currently limiting the metadata generation to the scenario where the artifact is a jar. However maven can support different types that support licensing such as zip

hboutemy: can you provide a concrete example, please?

Patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 8332ef3..9d76359 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -4,6 +4,10 @@ updates:
     directory: "/"
     schedule:
       interval: "daily"
+    ignore:
+      # JsonUnit `2.x` is the last Java 8 compatible major release
+      - dependency-name: "net.javacrumbs.json-unit:*"
+        update-types: ["version-update:semver-major"]
 
   - package-ecosystem: "github-actions"
     directory: "/"
diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml
index a95be10..88ba90a 100644
--- a/.github/workflows/maven.yml
+++ b/.github/workflows/maven.yml
@@ -10,7 +10,7 @@ jobs:
     steps:
     - uses: actions/checkout@v4.1.1
     - name: Set up JDK 1.8
-      uses: actions/setup-java@v3
+      uses: actions/setup-java@v4
       with:
         distribution: 'adopt'
         java-version: '8'
diff --git a/pom.xml b/pom.xml
index 17799cf..9ea6051 100644
--- a/pom.xml
+++ b/pom.xml
@@ -70,7 +70,8 @@
         <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
         <project.build.outputTimestamp>2023-10-30T00:44:18Z</project.build.outputTimestamp>
         <junit.version>4.13.2</junit.version>
-        <junit5.version>5.10.0</junit5.version>
+        <junit5.version>5.10.1</junit5.version>
+        <plugin-tools.version>3.11.0</plugin-tools.version>
     </properties>
 
     <scm>
@@ -123,7 +124,7 @@
         <dependency>
             <groupId>org.apache.commons</groupId>
             <artifactId>commons-lang3</artifactId>
-            <version>3.13.0</version>
+            <version>3.14.0</version>
         </dependency>
         <dependency>
             <groupId>org.apache.maven</groupId>
@@ -164,7 +165,7 @@
         <dependency>
             <groupId>org.apache.maven.plugin-tools</groupId>
             <artifactId>maven-plugin-annotations</artifactId>
-            <version>3.10.1</version>
+            <version>${plugin-tools.version}</version>
             <scope>provided</scope>
         </dependency>
 
@@ -203,6 +204,12 @@
             <artifactId>junit-vintage-engine</artifactId>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>net.javacrumbs.json-unit</groupId>
+            <artifactId>json-unit-assertj</artifactId>
+            <version>2.38.0</version>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
     <dependencyManagement>
@@ -241,7 +248,7 @@
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-plugin-plugin</artifactId>
-                <version>3.10.1</version>
+                <version>${plugin-tools.version}</version>
             </plugin>
             <plugin>
                 <groupId>org.eclipse.sisu</groupId>
@@ -308,7 +315,7 @@
                 <plugin>
                     <groupId>org.apache.maven.plugins</groupId>
                     <artifactId>maven-compiler-plugin</artifactId>
-                    <version>3.11.0</version>
+                    <version>3.12.1</version>
                 </plugin>
                 <plugin>
                     <groupId>org.apache.maven.plugins</groupId>
@@ -321,7 +328,7 @@
                 <plugin>
                     <groupId>org.apache.maven.plugins</groupId>
                     <artifactId>maven-javadoc-plugin</artifactId>
-                    <version>3.6.0</version>
+                    <version>3.6.3</version>
                 </plugin>
                 <plugin>
                     <groupId>org.apache.maven.plugins</groupId>
@@ -331,7 +338,7 @@
                 <plugin>
                     <groupId>org.apache.maven.plugins</groupId>
                     <artifactId>maven-project-info-reports-plugin</artifactId>
-                    <version>3.4.5</version>
+                    <version>3.5.0</version>
                 </plugin>
             </plugins>
         </pluginManagement>
@@ -436,7 +443,7 @@
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-plugin-report-plugin</artifactId>
-                <version>3.10.1</version>
+                <version>${plugin-tools.version}</version>
             </plugin>
         </plugins>
     </reporting>
diff --git a/src/it/makeAggregateBom/skipped/deploy-config-force/pom.xml b/src/it/makeAggregateBom/skipped/deploy-config-force/pom.xml
new file mode 100644
index 0000000..3de8ae5
--- /dev/null
+++ b/src/it/makeAggregateBom/skipped/deploy-config-force/pom.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  This file is part of CycloneDX Maven Plugin.
+
+  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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.cyclonedx.its</groupId>
+        <artifactId>skipped</artifactId>
+        <version>1.0-SNAPSHOT</version>
+    </parent>
+    <artifactId>deploy-config-force</artifactId>
+
+    <build>
+      <plugins>
+        <plugin>
+          <groupId>org.cyclonedx</groupId>
+          <artifactId>cyclonedx-maven-plugin</artifactId>
+          <configuration>
+            <skipNotDeployed>false</skipNotDeployed>
+          </configuration>
+        </plugin>
+        <plugin>
+          <groupId>org.apache.maven.plugins</groupId>
+          <artifactId>maven-deploy-plugin</artifactId>
+          <version>3.1.1</version>
+          <configuration>
+            <skip>true</skip>
+          </configuration>
+        </plugin>
+      </plugins>
+    </build>
+</project>
diff --git a/src/it/makeAggregateBom/skipped/deploy-property-force/pom.xml b/src/it/makeAggregateBom/skipped/deploy-property-force/pom.xml
new file mode 100644
index 0000000..8baca03
--- /dev/null
+++ b/src/it/makeAggregateBom/skipped/deploy-property-force/pom.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  This file is part of CycloneDX Maven Plugin.
+
+  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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.cyclonedx.its</groupId>
+        <artifactId>skipped</artifactId>
+        <version>1.0-SNAPSHOT</version>
+    </parent>
+    <artifactId>deploy-property-force</artifactId>
+
+    <properties>
+        <maven.deploy.skip>true</maven.deploy.skip>
+        <cyclonedx.skipNotDeployed>false</cyclonedx.skipNotDeployed>
+    </properties>
+</project>
diff --git a/src/it/makeAggregateBom/skipped/pom.xml b/src/it/makeAggregateBom/skipped/pom.xml
index 784fab3..c802c49 100644
--- a/src/it/makeAggregateBom/skipped/pom.xml
+++ b/src/it/makeAggregateBom/skipped/pom.xml
@@ -31,7 +31,9 @@
 
     <modules>
       <module>deploy-property</module>
+      <module>deploy-property-force</module>
       <module>deploy-config</module>
+      <module>deploy-config-force</module>
       <module>nexus-property</module>
       <module>nexus-config</module>
     </modules>
diff --git a/src/it/makeAggregateBom/verify.groovy b/src/it/makeAggregateBom/verify.groovy
index ec101a4..3629ae9 100644
--- a/src/it/makeAggregateBom/verify.groovy
+++ b/src/it/makeAggregateBom/verify.groovy
@@ -30,6 +30,8 @@ assertBomFiles("impls/target/bom", false)
 assertBomFiles("impls/impl-A/target/bom", false)
 assertBomFiles("impls/impl-B/target/bom", false)
 assertBomFiles("skipped/target/bom", false)
+assertBomFiles("skipped/deploy-config-force/target/bom", false)
+assertBomFiles("skipped/deploy-property-force/target/bom", false)
 
 assertNoBomFiles("skipped/deploy-config/target/bom")
 assertNoBomFiles("skipped/deploy-property/target/bom")
@@ -38,16 +40,16 @@ assertNoBomFiles("skipped/nexus-property/target/bom")
 
 var buildLog = new File(basedir, "build.log").text
 
-assert 13 == (buildLog =~ /\[INFO\] CycloneDX: Resolving Dependencies/).size()
+assert 17 == (buildLog =~ /\[INFO\] CycloneDX: Resolving Dependencies/).size()
 assert 2 == (buildLog =~ /\[INFO\] CycloneDX: Resolving Aggregated Dependencies/).size()
 
-// 15 = 7 modules for main cyclonedx-makeAggregateBom execution
+// 19 = 9 modules for main cyclonedx-makeAggregateBom execution
 //    + 1 for root module cyclonedx-makeAggregateBom-root-only execution
-//    + 7 modules for additional cyclonedx-makeBom execution
-assert 15 == (buildLog =~ /\[INFO\] CycloneDX: Writing and validating BOM \(XML\)/).size()
-assert 15 == (buildLog =~ /\[INFO\] CycloneDX: Writing and validating BOM \(JSON\)/).size()
-// cyclonedx-makeAggregateBom-root-only execution skips 5 non-root modules
-assert 6 == (buildLog =~ /\[INFO\] Skipping CycloneDX on non-execution root/).size()
+//    + 9 modules for additional cyclonedx-makeBom execution
+assert 19 == (buildLog =~ /\[INFO\] CycloneDX: Writing and validating BOM \(XML\)/).size()
+assert 19 == (buildLog =~ /\[INFO\] CycloneDX: Writing and validating BOM \(JSON\)/).size()
+// cyclonedx-makeAggregateBom-root-only execution skips 7 non-root modules
+assert 8 == (buildLog =~ /\[INFO\] Skipping CycloneDX on non-execution root/).size()
 
 // [WARNING] artifact org.cyclonedx.its:api:xml:cyclonedx:1.0-SNAPSHOT already attached, replace previous instance
 assert 0 == (buildLog =~ /-SNAPSHOT already attached, replace previous instance/).size()
@@ -72,6 +74,8 @@ assertBomEqualsNonAggregate("util/target/bom")
 assertBomEqualsNonAggregate("impls/target/bom")
 assertBomEqualsNonAggregate("impls/impl-A/target/bom")
 assertBomEqualsNonAggregate("impls/impl-B/target/bom")
+assertBomEqualsNonAggregate("skipped/deploy-config-force/target/bom")
+assertBomEqualsNonAggregate("skipped/deploy-property-force/target/bom")
 
 // dependencies for root component in makeAggregateBom is the list of modules
 String bom = new File(basedir, 'target/bom.xml').text
diff --git a/src/it/makeBom/verify.groovy b/src/it/makeBom/verify.groovy
index 28f59f0..4f54802 100644
--- a/src/it/makeBom/verify.groovy
+++ b/src/it/makeBom/verify.groovy
@@ -9,7 +9,6 @@ assert bomFileXml.text.contains('<reference type="website"><url>https://github.c
 assert !bomFileXml.text.contains('<property name="maven.optional.unused">')
 
 // Reproducible Builds
-assert !bomFileJson.text.contains('"serialNumber"')
 assert !bomFileJson.text.contains('"timestamp"')
 assert bomFileJson.text.contains('"name" : "cdx:reproducible",')
 assert bomFileJson.text.contains('"value" : "enabled"')
diff --git a/src/main/java/org/cyclonedx/maven/BaseCycloneDxMojo.java b/src/main/java/org/cyclonedx/maven/BaseCycloneDxMojo.java
index 4b50480..8107d77 100644
--- a/src/main/java/org/cyclonedx/maven/BaseCycloneDxMojo.java
+++ b/src/main/java/org/cyclonedx/maven/BaseCycloneDxMojo.java
@@ -39,6 +39,7 @@ import org.cyclonedx.maven.ProjectDependenciesConverter.BomDependencies;
 import org.cyclonedx.model.Bom;
 import org.cyclonedx.model.Component;
 import org.cyclonedx.model.Dependency;
+import org.cyclonedx.model.ExternalReference;
 import org.cyclonedx.model.Metadata;
 import org.cyclonedx.model.Property;
 import org.cyclonedx.parsers.JsonParser;
@@ -217,6 +218,14 @@ public abstract class BaseCycloneDxMojo extends AbstractMojo {
     @Parameter( defaultValue = "${project.build.outputTimestamp}" )
     private String outputTimestamp;
 
+    /**
+     * External references to be added to <code>$.metadata.component.externalReferences[]</code>.
+     *
+     * @since 2.7.11
+     */
+    @Parameter
+    private ExternalReference[] externalReferences;
+
     @org.apache.maven.plugins.annotations.Component
     private MavenProjectHelper mavenProjectHelper;
 
@@ -278,9 +287,18 @@ public abstract class BaseCycloneDxMojo extends AbstractMojo {
         return Boolean.parseBoolean(System.getProperty("cyclonedx.skip", Boolean.toString(skip)));
     }
 
+    protected String getSkipReason() {
+        return null;
+    }
+
     public void execute() throws MojoExecutionException {
         if (shouldSkip()) {
-            getLog().info("Skipping CycloneDX");
+            final String skipReason = getSkipReason();
+            if (skipReason != null) {
+                getLog().info("Skipping CycloneDX goal, because " + skipReason);
+            } else {
+                getLog().info("Skipping CycloneDX goal");
+            }
             return;
         }
         logParameters();
@@ -292,7 +310,7 @@ public abstract class BaseCycloneDxMojo extends AbstractMojo {
 
         String analysis = extractComponentsAndDependencies(topLevelComponents, componentMap, dependencyMap);
         if (analysis != null) {
-            final Metadata metadata = modelConverter.convert(project, projectType, schemaVersion(), includeLicenseText);
+            final Metadata metadata = modelConverter.convert(project, projectType, schemaVersion(), includeLicenseText, externalReferences);
 
             if (schemaVersion().getVersion() >= 1.3) {
                 metadata.addProperty(newProperty("maven.goal", analysis));
@@ -334,7 +352,6 @@ public abstract class BaseCycloneDxMojo extends AbstractMojo {
 
             if (outputTimestamp != null) {
                 // activate Reproducible Builds mode
-                includeBomSerialNumber = false;
                 metadata.setTimestamp(null);
                 if (schemaVersion().getVersion() >= 1.3) {
                     metadata.addProperty(newProperty("cdx:reproducible", "enabled"));
@@ -342,7 +359,8 @@ public abstract class BaseCycloneDxMojo extends AbstractMojo {
             }
 
             if (schemaVersion().getVersion() >= 1.1 && includeBomSerialNumber) {
-                bom.setSerialNumber("urn:uuid:" + UUID.randomUUID());
+                String serialNumber = generateSerialNumber(metadata.getProperties());
+                bom.setSerialNumber(serialNumber);
             }
 
             if (schemaVersion().getVersion() >= 1.2) {
@@ -371,6 +389,21 @@ public abstract class BaseCycloneDxMojo extends AbstractMojo {
         }
     }
 
+    private String generateSerialNumber(List<Property> properties) {
+        String gav = String.format("%s:%s:%s", project.getGroupId(), project.getArtifactId(), project.getVersion());
+        StringBuilder sb = new StringBuilder(gav);
+        if (properties != null) {
+            for(Property prop: properties) {
+                sb.append(';');
+                sb.append(prop.getName());
+                sb.append('=');
+                sb.append(prop.getValue());
+            }
+        }
+        UUID uuid = UUID.nameUUIDFromBytes(sb.toString().getBytes(StandardCharsets.UTF_8));
+        return String.format("urn:uuid:%s", uuid);
+    }
+
     private void saveBom(Bom bom) throws ParserConfigurationException, IOException, GeneratorException,
             MojoExecutionException {
         if ("all".equalsIgnoreCase(outputFormat) || "xml".equalsIgnoreCase(outputFormat)) {
@@ -475,7 +508,7 @@ public abstract class BaseCycloneDxMojo extends AbstractMojo {
      *
      * @return Component.Scope - REQUIRED, OPTIONAL or null if it cannot be determined
      *
-     * @see detectUnusedForOptionalScope
+     * @see #detectUnusedForOptionalScope
      */
     private Component.Scope getComponentScope(Artifact artifact, ProjectDependencyAnalysis projectDependencyAnalysis) {
         if (detectUnusedForOptionalScope) {
diff --git a/src/main/java/org/cyclonedx/maven/CycloneDxAggregateMojo.java b/src/main/java/org/cyclonedx/maven/CycloneDxAggregateMojo.java
index 7675b40..ea5296f 100644
--- a/src/main/java/org/cyclonedx/maven/CycloneDxAggregateMojo.java
+++ b/src/main/java/org/cyclonedx/maven/CycloneDxAggregateMojo.java
@@ -91,7 +91,7 @@ public class CycloneDxAggregateMojo extends CycloneDxMojo {
         if (excludeTestProject && mavenProject.getArtifactId().contains("test")) {
             shouldExclude = true;
         }
-        if (!BaseCycloneDxMojo.isDeployable(mavenProject)) {
+        if (skipNotDeployed && !BaseCycloneDxMojo.isDeployable(mavenProject)) {
             shouldExclude = true;
         }
         return shouldExclude;
diff --git a/src/main/java/org/cyclonedx/maven/CycloneDxMojo.java b/src/main/java/org/cyclonedx/maven/CycloneDxMojo.java
index 44f3b38..a708dac 100644
--- a/src/main/java/org/cyclonedx/maven/CycloneDxMojo.java
+++ b/src/main/java/org/cyclonedx/maven/CycloneDxMojo.java
@@ -47,6 +47,14 @@ import java.util.Set;
 )
 public class CycloneDxMojo extends BaseCycloneDxMojo {
 
+    /**
+     * Only runs this goal or adds to aggregate SBOM if the module does not skip deploy.
+     *
+     * @since 2.7.11
+     */
+    @Parameter(property = "cyclonedx.skipNotDeployed", defaultValue = "true", required = false)
+    protected boolean skipNotDeployed = true;
+
     /**
      * Specify the Maven project dependency analyzer to use (plexus component role-hint). By default,
      * <a href="https://maven.apache.org/shared/maven-dependency-analyzer/">maven-dependency-analyzer</a>'s one
@@ -97,7 +105,15 @@ public class CycloneDxMojo extends BaseCycloneDxMojo {
     @Override
     protected boolean shouldSkip() {
         // The list of artifacts would be empty
-        return super.shouldSkip() || !isDeployable(getProject());
+        return super.shouldSkip() || skipNotDeployed && !isDeployable(getProject());
+    }
+
+    @Override
+    protected String getSkipReason() {
+        if (super.shouldSkip()) {
+            return super.getSkipReason();
+        }
+        return "module skips deploy";
     }
 
     protected String extractComponentsAndDependencies(final Set<String> topLevelComponents, final Map<String, Component> components, final Map<String, Dependency> dependencies) throws MojoExecutionException {
diff --git a/src/main/java/org/cyclonedx/maven/DefaultModelConverter.java b/src/main/java/org/cyclonedx/maven/DefaultModelConverter.java
index b8aaa20..24249b0 100644
--- a/src/main/java/org/cyclonedx/maven/DefaultModelConverter.java
+++ b/src/main/java/org/cyclonedx/maven/DefaultModelConverter.java
@@ -18,18 +18,8 @@
  */
 package org.cyclonedx.maven;
 
-import java.io.File;
-import java.io.IOException;
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.util.List;
-import java.util.Properties;
-import java.util.TreeMap;
-
-import javax.inject.Inject;
-import javax.inject.Named;
-import javax.inject.Singleton;
-
+import com.github.packageurl.MalformedPackageURLException;
+import com.github.packageurl.PackageURL;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.maven.artifact.Artifact;
 import org.apache.maven.artifact.DefaultArtifact;
@@ -55,8 +45,18 @@ import org.eclipse.aether.artifact.ArtifactProperties;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import com.github.packageurl.MalformedPackageURLException;
-import com.github.packageurl.PackageURL;
+import javax.inject.Inject;
+import javax.inject.Named;
+import javax.inject.Singleton;
+import java.io.File;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Properties;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
 
 @Singleton
 @Named
@@ -172,21 +172,29 @@ public class DefaultModelConverter implements ModelConverter {
         if (CycloneDxSchema.Version.VERSION_10 != schemaVersion) {
             component.setBomRef(component.getPurl());
         }
-        if (isDescribedArtifact(artifact)) {
-            try {
-                final MavenProject project = getEffectiveMavenProject(artifact);
-                if (project != null) {
-                    extractComponentMetadata(project, component, schemaVersion, includeLicenseText);
-                }
-            } catch (ProjectBuildingException e) {
-                if (logger.isDebugEnabled()) {
-                    logger.warn("Unable to create Maven project for " + artifact.getId() + " from repository.", e);
-                } else {
-                    logger.warn("Unable to create Maven project for " + artifact.getId() + " from repository.");
-                }
+        try {
+            final MavenProject project = getEffectiveMavenProject(artifact);
+            if (project != null) {
+                extractComponentMetadata(project, component, schemaVersion, includeLicenseText);
+            }
+        } catch (ProjectBuildingException e) {
+            if (logger.isDebugEnabled()) {
+                logger.warn("Unable to create Maven project for " + artifact.getId() + " from repository.", e);
+            } else {
+                logger.warn("Unable to create Maven project for " + artifact.getId() + " from repository.");
             }
         }
         return component;
+
+    }
+
+    private static void setExternalReferences(Component component, ExternalReference[] externalReferences) {
+        if (externalReferences == null || externalReferences.length == 0) {
+            return;
+        }
+        // We need a mutable `List`, hence `Arrays.asList()` won't work.
+        List<ExternalReference> externalReferences_ = Arrays.stream(externalReferences).collect(Collectors.toList());
+        component.setExternalReferences(externalReferences_);
     }
 
     private boolean isModified(Artifact artifact) {
@@ -194,16 +202,6 @@ public class DefaultModelConverter implements ModelConverter {
         return false;
     }
 
-    /**
-     * Returns true for any artifact type which will positively have a POM that
-     * describes the artifact.
-     * @param artifact the artifact
-     * @return true if artifact will have a POM, false if not
-     */
-    private boolean isDescribedArtifact(Artifact artifact) {
-        return artifact.getType().equalsIgnoreCase("jar");
-    }
-
     /**
      * Extracts data from a project and adds the data to the component.
      * @param project the project to extract data from
@@ -341,7 +339,7 @@ public class DefaultModelConverter implements ModelConverter {
     }
 
     @Override
-    public Metadata convert(final MavenProject project, String projectType, CycloneDxSchema.Version schemaVersion, boolean includeLicenseText) {
+    public Metadata convert(final MavenProject project, String projectType, CycloneDxSchema.Version schemaVersion, boolean includeLicenseText, ExternalReference[] externalReferences) {
         final Tool tool = new Tool();
         final Properties properties = readPluginProperties();
         tool.setVendor(properties.getProperty("vendor"));
@@ -367,6 +365,7 @@ public class DefaultModelConverter implements ModelConverter {
         component.setType(resolveProjectType(projectType));
         component.setPurl(generatePackageUrl(project.getArtifact()));
         component.setBomRef(component.getPurl());
+        setExternalReferences(component, externalReferences);
         extractComponentMetadata(project, component, schemaVersion, includeLicenseText);
 
         final Metadata metadata = new Metadata();
diff --git a/src/main/java/org/cyclonedx/maven/ModelConverter.java b/src/main/java/org/cyclonedx/maven/ModelConverter.java
index 98721e2..77b9d72 100644
--- a/src/main/java/org/cyclonedx/maven/ModelConverter.java
+++ b/src/main/java/org/cyclonedx/maven/ModelConverter.java
@@ -22,6 +22,7 @@ import org.apache.maven.artifact.Artifact;
 import org.apache.maven.project.MavenProject;
 import org.cyclonedx.CycloneDxSchema;
 import org.cyclonedx.model.Component;
+import org.cyclonedx.model.ExternalReference;
 import org.cyclonedx.model.Metadata;
 
 /**
@@ -57,7 +58,9 @@ public interface ModelConverter {
      * @param projectType the target CycloneDX component type
      * @param schemaVersion the target CycloneDX schema version
      * @param includeLicenseText should license text be included in bom?
+     * @param externalReferences the external references
      * @return a CycloneDX Metadata object
      */
-    Metadata convert(MavenProject project, String projectType, CycloneDxSchema.Version schemaVersion, boolean includeLicenseText);
+    Metadata convert(MavenProject project, String projectType, CycloneDxSchema.Version schemaVersion, boolean includeLicenseText, ExternalReference[] externalReferences);
+
 }


Test patch:
diff --git a/src/test/java/org/cyclonedx/maven/ExternalReferenceTest.java b/src/test/java/org/cyclonedx/maven/ExternalReferenceTest.java
new file mode 100644
index 0000000..d725fbd
--- /dev/null
+++ b/src/test/java/org/cyclonedx/maven/ExternalReferenceTest.java
@@ -0,0 +1,65 @@
+package org.cyclonedx.maven;
+
+import io.takari.maven.testing.executor.MavenRuntime.MavenRuntimeBuilder;
+import io.takari.maven.testing.executor.MavenVersions;
+import io.takari.maven.testing.executor.junit.MavenJUnitTestRunner;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
+
+/**
+ * Verifies external references are populated as expected.
+ */
+@RunWith(MavenJUnitTestRunner.class)
+@MavenVersions({"3.6.3"})
+public class ExternalReferenceTest extends BaseMavenVerifier {
+
+    public ExternalReferenceTest(MavenRuntimeBuilder runtimeBuilder) throws Exception {
+        super(runtimeBuilder);
+    }
+
+    @Test
+    public void testAddedExternalReferences() throws Exception {
+
+        // Create the verifier
+        File projDir = resources.getBasedir("external-reference");
+        verifier
+                .forProject(projDir)
+                .withCliOption("-Dcyclonedx-maven-plugin.version=" + getCurrentVersion())
+                .withCliOption("-X")
+                .withCliOption("-B")
+                .execute("clean", "verify")
+                .assertErrorFreeLog();
+
+        // Verify parent metadata
+        assertExternalReferences(
+                new File(projDir, "target/bom.json"),
+                "$.metadata.component.externalReferences[?(@.type=='chat')].url",
+                Collections.singleton("https://acme.com/parent"));
+
+        // Verify child metadata
+        assertExternalReferences(
+                new File(projDir, "child/target/bom.json"),
+                "$.metadata.component.externalReferences[?(@.type=='chat')].url",
+                Arrays.asList("https://acme.com/parent", "https://acme.com/child"));
+
+    }
+
+    private static void assertExternalReferences(File bomFile, String jsonPath, Iterable<Object> expectedValues) throws IOException {
+        byte[] bomJsonBytes = Files.readAllBytes(bomFile.toPath());
+        String bomJson = new String(bomJsonBytes, StandardCharsets.UTF_8);
+        assertThatJson(bomJson)
+                .inPath(jsonPath)
+                .isArray()
+                .containsOnlyOnceElementsOf(expectedValues);
+    }
+
+}
diff --git a/src/test/java/org/cyclonedx/maven/Issue420Test.java b/src/test/java/org/cyclonedx/maven/Issue420Test.java
new file mode 100644
index 0000000..9638dcd
--- /dev/null
+++ b/src/test/java/org/cyclonedx/maven/Issue420Test.java
@@ -0,0 +1,73 @@
+package org.cyclonedx.maven;
+
+import io.takari.maven.testing.executor.MavenRuntime.MavenRuntimeBuilder;
+import io.takari.maven.testing.executor.MavenVersions;
+import io.takari.maven.testing.executor.junit.MavenJUnitTestRunner;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import javax.annotation.Nullable;
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+
+import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
+
+/**
+ * Verifies that serial numbers are generated deterministically.
+ *
+ * @see <a href="https://github.com/CycloneDX/cyclonedx-maven-plugin/issues/420">Issue 420</a>
+ */
+@RunWith(MavenJUnitTestRunner.class)
+@MavenVersions({"3.6.3"})
+public class Issue420Test extends BaseMavenVerifier {
+
+    public Issue420Test(MavenRuntimeBuilder runtimeBuilder) throws Exception {
+        super(runtimeBuilder);
+    }
+
+    @Test
+    public void testDefaults() throws Exception {
+        test(new String[0], "urn:uuid:af111a48-2091-3e2e-ad2e-60b1975b651d");
+    }
+
+    @Test
+    public void testDefaultsWhenSerialNumberIsDisabled() throws Exception {
+        test(new String[]{"-DincludeBomSerialNumber=false"}, null);
+    }
+
+    @Test
+    public void testWhenOutputTimestampIsSet() throws Exception {
+        test(new String[]{"-Dproject.build.outputTimestamp=2023-11-08T00:00:00Z"}, "urn:uuid:3e383c4c-ef61-3eba-8214-3ecd46c4bbee");
+    }
+
+    @Test
+    public void testWhenOutputTimestampIsSetAndSerialNumberIsDisabled() throws Exception {
+        test(new String[]{"-Dproject.build.outputTimestamp=2023-11-08T00:00:00Z", "-DincludeBomSerialNumber=false"}, null);
+    }
+
+    private void test(String[] cliOptions, @Nullable String expectedSerialNumber) throws Exception {
+        File projDir = resources.getBasedir("issue-420");
+        verifier
+                .forProject(projDir)
+                .withCliOption("-Dcurrent.version=" + getCurrentVersion())
+                .withCliOption("-B")
+                .withCliOption("-Dmaven.test.skip")
+                .withCliOptions(cliOptions)
+                .execute("clean", "verify")
+                .assertErrorFreeLog();
+        assertSerialNumber(projDir, expectedSerialNumber);
+    }
+
+    private static void assertSerialNumber(File projDir, @Nullable String expectedSerialNumber) throws Exception {
+        File bomFile = new File(projDir, "target/bom.json");
+        String bomJson = new String(Files.readAllBytes(bomFile.toPath()), StandardCharsets.UTF_8);
+        String serialNumberKey = "serialNumber";
+        if (expectedSerialNumber == null) {
+            assertThatJson(bomJson).isObject().doesNotContainKey(serialNumberKey);
+        } else {
+            assertThatJson(bomJson).isObject().containsEntry(serialNumberKey, expectedSerialNumber);
+        }
+    }
+
+}
diff --git a/src/test/resources/external-reference/child/pom.xml b/src/test/resources/external-reference/child/pom.xml
new file mode 100644
index 0000000..1455508
--- /dev/null
+++ b/src/test/resources/external-reference/child/pom.xml
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>com.acme</groupId>
+        <artifactId>parent</artifactId>
+        <version>${revision}</version>
+    </parent>
+
+    <artifactId>child</artifactId>
+
+    <dependencies>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>4.1</version>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.cyclonedx</groupId>
+                <artifactId>cyclonedx-maven-plugin</artifactId>
+                <version>${cyclonedx-maven-plugin.version}</version>
+                <configuration>
+                    <externalReferences combine.children="append">
+                        <externalReference>
+                            <type>CHAT</type>
+                            <url>https://acme.com/child</url>
+                        </externalReference>
+                    </externalReferences>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+
+</project>
diff --git a/src/test/resources/external-reference/pom.xml b/src/test/resources/external-reference/pom.xml
new file mode 100644
index 0000000..2e4c93d
--- /dev/null
+++ b/src/test/resources/external-reference/pom.xml
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+
+    <groupId>com.acme</groupId>
+    <artifactId>parent</artifactId>
+    <packaging>pom</packaging>
+    <version>${revision}</version>
+
+    <licenses>
+        <license>
+            <name>Apache-2.0</name>
+            <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
+        </license>
+    </licenses>
+
+    <properties>
+        <revision>1.0.0</revision>
+        <maven.compiler.source>1.8</maven.compiler.source>
+        <maven.compiler.target>1.8</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+    </properties>
+
+    <modules>
+        <module>child</module>
+    </modules>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.cyclonedx</groupId>
+                <artifactId>cyclonedx-maven-plugin</artifactId>
+                <version>${cyclonedx-maven-plugin.version}</version>
+                <executions>
+                    <execution>
+                        <id>generate-sbom</id>
+                        <goals>
+                            <goal>makeAggregateBom</goal>
+                        </goals>
+                        <phase>verify</phase>
+                        <configuration>
+                            <outputFormat>json</outputFormat>
+                            <externalReferences>
+                                <externalReference>
+                                    <type>CHAT</type>
+                                    <url>https://acme.com/parent</url>
+                                </externalReference>
+                            </externalReferences>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+
+</project>
diff --git a/src/test/resources/issue-420/module-A/pom.xml b/src/test/resources/issue-420/module-A/pom.xml
new file mode 100644
index 0000000..f4244d8
--- /dev/null
+++ b/src/test/resources/issue-420/module-A/pom.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>com.example</groupId>
+        <artifactId>issue-420</artifactId>
+        <version>${revision}</version>
+    </parent>
+
+    <artifactId>issue-420-module-A</artifactId>
+
+    <dependencies>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>4.1</version>
+        </dependency>
+    </dependencies>
+
+</project>
diff --git a/src/test/resources/issue-420/pom.xml b/src/test/resources/issue-420/pom.xml
new file mode 100644
index 0000000..efae20a
--- /dev/null
+++ b/src/test/resources/issue-420/pom.xml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+
+    <groupId>com.example</groupId>
+    <artifactId>issue-420</artifactId>
+    <packaging>pom</packaging>
+    <version>${revision}</version>
+
+    <licenses>
+        <license>
+            <name>Apache-2.0</name>
+            <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
+        </license>
+    </licenses>
+
+    <modules>
+        <module>module-A</module>
+    </modules>
+
+    <properties>
+        <revision>1.0.0</revision>
+        <maven.compiler.source>1.8</maven.compiler.source>
+        <maven.compiler.target>1.8</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+    </properties>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.cyclonedx</groupId>
+                <artifactId>cyclonedx-maven-plugin</artifactId>
+                <version>${current.version}</version>
+                <executions>
+                    <execution>
+                        <phase>verify</phase>
+                        <goals>
+                            <goal>makeAggregateBom</goal>
+                        </goals>
+                    </execution>
+                </executions>
+                <configuration>
+                    <outputFormat>json</outputFormat>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+
+</project>