diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy index 7af23167462..db20a07e60b 100644 --- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy @@ -140,7 +140,10 @@ class SbomPlugin implements Plugin { configureNormalization(project) ensureLicensesValidated(project) - // sboms are only published to Grails jar files at this time + // sboms are only published to Grails jar files at this time. Projects that produce a fat + // jar via com.gradleup.shadow are responsible for wiring the SBOM into their shadowJar + // task in their own build.gradle (see grails-forge/grails-cli/build.gradle), since fat-jar + // packaging is a project-specific concern and shadow is not a dependency of this plugin. publishSbomForJarProjects(project, sbomOutputLocation) } @@ -261,10 +264,20 @@ class SbomPlugin implements Plugin { } } - // force the serialNumber to be reproducible by removing it & recalculating + // force the serialNumber to be reproducible by clearing it & recalculating. + // Mix the projectPath into the UUID seed so two modules whose post-processed + // BOM JSON happens to be identical (for example, empty BOM platforms with no + // runtime dependencies, or modules whose metadata.component is filled in + // identically by the CycloneDX plugin) still receive distinct serialNumbers + // as required by the CycloneDX specification. Including projectPath preserves + // reproducibility because the same project path + same content always yields + // the same UUID across rebuilds. This guards against collisions introduced by + // CycloneDX 3.0.0 / Gradle 9 metadata changes. + // See: https://cyclonedx.org/docs/1.6/json/#serialNumber bom['serialNumber'] = '' - def withOutSerial = JsonOutput.prettyPrint(JsonOutput.toJson(bom)) - def uuid = UUID.nameUUIDFromBytes(withOutSerial.getBytes(StandardCharsets.UTF_8.name())) + def withoutSerial = JsonOutput.prettyPrint(JsonOutput.toJson(bom)) + def uuidSeed = "${projectPath}\n${withoutSerial}" + def uuid = UUID.nameUUIDFromBytes(uuidSeed.getBytes(StandardCharsets.UTF_8)) bom['serialNumber'] = "urn:uuid:$uuid".toString() f.setText(JsonOutput.prettyPrint(JsonOutput.toJson(bom)), StandardCharsets.UTF_8.name()) diff --git a/grails-forge/grails-cli-shadow/build.gradle b/grails-forge/grails-cli-shadow/build.gradle index 6978124d70c..21c1ee17e80 100644 --- a/grails-forge/grails-cli-shadow/build.gradle +++ b/grails-forge/grails-cli-shadow/build.gradle @@ -77,6 +77,12 @@ shadowJarTask.configure { ShadowJar it -> it.exclude( 'META-INF/DEPENDENCIES', // until we publish our own SBOM, this won't be correct so exclude + // This module does not apply org.apache.grails.buildsrc.sbom (it's an intermediate build + // artifact, not published). Without this exclude, shadow's first-wins merge picks one of + // the bundled transitive META-INF/sbom.json files (typically grails-shell-cli's), + // producing a fat jar whose SBOM describes the wrong module and shares its serialNumber + // with whichever sibling jar happens to win the merge - violating CycloneDX 1.6. + 'META-INF/sbom.json', 'about.html' // restatement of the Eclipse Distribution License - Version 1.0 for jakarta ) } diff --git a/grails-forge/grails-cli/build.gradle b/grails-forge/grails-cli/build.gradle index 79b4657d98c..1f16d876cf3 100644 --- a/grails-forge/grails-cli/build.gradle +++ b/grails-forge/grails-cli/build.gradle @@ -18,6 +18,7 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar import org.apache.grails.forge.buildlogic.shadowjar.GrailsGroovyExtensionTransformer import org.apache.grails.forge.buildlogic.shadowjar.GrailsShadowLicenseTransform import org.apache.grails.forge.buildlogic.shadowjar.GrailsShadowNoticeTransform +import org.cyclonedx.gradle.CyclonedxDirectTask plugins { id 'groovy' @@ -92,6 +93,16 @@ jarTask.configure { Jar it -> } } +// The shadowJar merges multiple jars (including transitive ones from grails-shell-cli, grails-forge-cli, +// etc.) into a single fat jar. Each of those source jars carries its own META-INF/sbom.json published by +// the org.apache.grails.buildsrc.sbom convention plugin, and shadow's first-wins merge would otherwise +// pick a transitive sbom.json (typically grails-shell-cli's) and produce a fat jar whose SBOM describes +// the wrong module. We exclude any incoming META-INF/sbom.json during the merge and then re-introduce +// this project's own SBOM (whose serialNumber is project-path-seeded and unique). This keeps fat-jar +// packaging concerns local to this project rather than leaking shadow knowledge into the generic +// org.apache.grails.buildsrc.sbom plugin. See: https://cyclonedx.org/docs/1.6/json/#serialNumber +TaskProvider cyclonedxDirectBomTask = tasks.named('cyclonedxDirectBom', CyclonedxDirectTask) + TaskProvider shadowJarTask = tasks.named('shadowJar', ShadowJar) shadowJarTask.configure { ShadowJar it -> it.archiveClassifier = 'all' @@ -122,8 +133,27 @@ shadowJarTask.configure { ShadowJar it -> 'META-INF/DEPENDENCIES', // until we publish our own SBOM, this won't be correct so exclude 'META-INF/grails-plugin.xml', // we do not start or compile a grails application so these files are not needed (grails-core, url mappings, etc plugins) 'META-INF/grails-plugin.xml.asc', // avoid signing artifacts + // Drop any incoming sbom.json that arrives via transitive jars during the shadow merge; + // re-introduced below from this project's own cyclonedxDirectBom output. + 'META-INF/sbom.json', 'about.html' // restatement of the Eclipse Distribution License - Version 1.0 for jakarta ) + + // Re-introduce this project's own SBOM after the merge (mirrors the regular jar wiring done by + // the org.apache.grails.buildsrc.sbom plugin). Mirrored only when skipJavaComponent is unset to + // match the convention used elsewhere in the build for projects that opt out of jar publication. + if (!project.findProperty('skipJavaComponent')) { + it.from(cyclonedxDirectBomTask.flatMap { CyclonedxDirectTask t -> t.jsonOutput }) { CopySpec spec -> + spec.into('META-INF') + spec.rename { + 'sbom.json' + } + } + it.manifest { Manifest manifest -> + manifest.attributes('Sbom-Location': 'META-INF/sbom.json') + manifest.attributes('Sbom-Format': 'CycloneDX') + } + } } // Make shadow jar a direct dependency of assemble instead of using deprecated archives configuration tasks.named('assemble').configure {