Repository: CycloneDX/cyclonedx-maven-plugin (java)

Issue:
Possiblity to create SBOM even for not deployed artifacts
With https://github.com/CycloneDX/cyclonedx-maven-plugin/pull/416 a feature was implemented which detects if the artifact of a Maven Module is deployed and the cyclonedx-maven-plugin will skip its execution, even when configured explicitly in this module.

There should be a config to disable this feature to be able to create SBOMs even when the deploy plugin is disabled. The state of deploying something to a Maven Central like repo or a Nexus/Artifactory instance has nothing to do with creating and providing SBOM, at least in a company context.

A Maven Module could produce some other artifact like a Docker image or an Zip which will be deployed somewhere else for production usage and I still want to create an SBOM for this.

Pull request:
Add a configuration option to skip undeployed artifacts
This adds a `<skipNotDeployed>` plugin configuration option. If set to `false`, even artifacts that do not have a deploy execution are used to generate the SBOM.

Closes #430

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..a9359ab 100644
--- a/pom.xml
+++ b/pom.xml
@@ -70,7 +70,7 @@
         <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>
     </properties>
 
     <scm>
@@ -123,7 +123,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 +164,7 @@
         <dependency>
             <groupId>org.apache.maven.plugin-tools</groupId>
             <artifactId>maven-plugin-annotations</artifactId>
-            <version>3.10.1</version>
+            <version>3.10.2</version>
             <scope>provided</scope>
         </dependency>
 
@@ -203,6 +203,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 +247,7 @@
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-plugin-plugin</artifactId>
-                <version>3.10.1</version>
+                <version>3.10.2</version>
             </plugin>
             <plugin>
                 <groupId>org.eclipse.sisu</groupId>
@@ -321,7 +327,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 +337,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 +442,7 @@
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-plugin-report-plugin</artifactId>
-                <version>3.10.1</version>
+                <version>3.10.2</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..ff304ea 100644
--- a/src/main/java/org/cyclonedx/maven/BaseCycloneDxMojo.java
+++ b/src/main/java/org/cyclonedx/maven/BaseCycloneDxMojo.java
@@ -278,9 +278,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();
@@ -334,7 +343,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 +350,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 +380,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 +499,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..981708f 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 if the module does not skip deploy.
+     *
+     * @since 2.8.0
+     */
+    @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 {


Test patch:
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/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>