jdaugherty commented on code in PR #15625:
URL: https://github.com/apache/grails-core/pull/15625#discussion_r3177485886


##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy:
##########
@@ -218,81 +251,101 @@ class SbomPlugin implements Plugin<Project> {
                 Provider<Boolean> isReproducibleBuildProvider = 
project.provider { lookupProperty(project, 'isReproducibleBuild') as boolean }
                 Provider<ZonedDateTime> buildDateProvider = project.provider { 
lookupProperty(project, 'buildDate') as ZonedDateTime }
                 doLast {
-                    // json schema is documented here: 
https://cyclonedx.org/docs/1.6/json/
-                    def rewriteSbom = { File f ->
-                        def bom = new JsonSlurper().parse(f)
-
-                        // timestamp is not reproducible: 
https://github.com/CycloneDX/cyclonedx-gradle-plugin/issues/292
-                        // Use a fixed epoch when SOURCE_DATE_EPOCH is not set 
so the SBOM is identical between
-                        // builds. This prevents the non-reproducible 
timestamp from changing the jar checksum
-                        // and cascading cache misses through the compile 
classpath of downstream projects.
-                        ZonedDateTime sbomTimestamp = 
isReproducibleBuildProvider.get() ?
-                                buildDateProvider.get() :
-                                Instant.EPOCH.atZone(ZoneOffset.UTC)
-                        bom['metadata']['timestamp'] = 
DateTimeFormatter.ISO_INSTANT.format(sbomTimestamp.truncatedTo(ChronoUnit.SECONDS))
-
-                        // components[*]
-                        def comps = (bom instanceof Map && bom.components 
instanceof List) ? bom.components : []
-                        comps.each { c ->
-                            // .licenses => choose a license that is 
compatible with ASF policy if multiple licensed
-                            if (c instanceof Map && c.licenses instanceof List 
&& !(c.licenses as List).empty) {
-                                def chosen = pickLicense(logger, projectName, 
c['bom-ref'] as String, c.licenses as List)
-                                if (chosen != null) {
-                                    c.licenses = [chosen]
-                                }
-                            }
+                    ZonedDateTime sbomTimestamp = 
isReproducibleBuildProvider.get() ?
+                            buildDateProvider.get() :
+                            Instant.EPOCH.atZone(ZoneOffset.UTC)
+                    sbomOutputLocation.get().with {
+                        rewriteSbomFile(it.asFile, logger, projectName, 
projectPath, sbomTimestamp)
+                    }
+                }
 
-                            // .hashes => project hashes are only generated if 
the jar file has been created,
-                            // which with a parallel build may not have 
occurred, so for any dependency that is a
-                            // project we exclude them
-                            if (c instanceof Map && c.hashes instanceof List 
&& !(c.hashes as List).empty) {
-                                def componentPath = c['bom-ref'] as String
-                                if (componentPath.contains('?project_path=')) {
-                                    c.remove('hashes')
-                                }
-                            }
-                        }
+            }
+        }
+    }
 
-                        // dependencies[*].dependsOn is not reproducible, so 
sort it
-                        def dependencies = (bom instanceof Map && 
bom.dependencies instanceof List) ? bom.dependencies : []
-                        dependencies.each { d ->
-                            if (d instanceof Map && d.dependsOn instanceof 
List && !(d.dependsOn as List).empty) {
-                                d.dependsOn = (d.dependsOn as List).sort()
-                            }
-                        }
+    /**
+     * Post-process the SBOM JSON for reproducibility: pin the timestamp, 
normalize
+     * dependency ordering, drop hashes for in-build project references, strip 
the
+     * {@code build-system} externalReference (auto-injected by the 
cyclonedx-gradle-plugin
+     * when run on GitHub Actions and unknowable from a local rebuild), and 
recompute the
+     * {@code serialNumber} as a deterministic UUID derived from project path 
+ content.
+     */
+    @CompileDynamic
+    private static void rewriteSbomFile(
+            File f,
+            org.gradle.api.logging.Logger logger,
+            String projectName,
+            String projectPath,
+            ZonedDateTime sbomTimestamp) {
+        Set<String> externalRefTypesToStrip = ['build-system'] as Set<String>
+        if (f == null || !f.exists() || f.length() == 0) {
+            logger.warn('SBOM file is missing or empty, skipping 
reproducibility rewrite: {}', f)
+            return
+        }
+        def bom = new JsonSlurper().parse(f)
 
-                        // 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 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())
-
-                        logger.info('Rewrote JSON SBOM ({}) to pick preferred 
license', projectPath)
-                    }
+        bom['metadata']['timestamp'] = 
DateTimeFormatter.ISO_INSTANT.format(sbomTimestamp.truncatedTo(ChronoUnit.SECONDS))
 
-                    sbomOutputLocation.get().with { rewriteSbom(it.asFile) }
+        def comps = (bom instanceof Map && bom.components instanceof List) ? 
bom.components : []
+        comps.each { c ->
+            if (c instanceof Map && c.licenses instanceof List && !(c.licenses 
as List).empty) {
+                def chosen = pickLicense(logger, projectName, c['bom-ref'] as 
String, c.licenses as List)
+                if (chosen != null) {
+                    c.licenses = [chosen]
                 }
+            }
 
+            if (c instanceof Map && c.hashes instanceof List && !(c.hashes as 
List).empty) {
+                def componentPath = c['bom-ref'] as String
+                if (componentPath.contains('?project_path=')) {
+                    c.remove('hashes')
+                }
+            }
+        }
+
+        def dependencies = (bom instanceof Map && bom.dependencies instanceof 
List) ? bom.dependencies : []
+        dependencies.each { d ->
+            if (d instanceof Map && d.dependsOn instanceof List && 
!(d.dependsOn as List).empty) {
+                d.dependsOn = (d.dependsOn as List).sort()
             }
         }
+
+        def componentMeta = (bom instanceof Map && bom.metadata instanceof 
Map) ? bom.metadata.component : null
+        if (componentMeta instanceof Map && componentMeta.externalReferences 
instanceof List) {
+            componentMeta.externalReferences = 
(componentMeta.externalReferences as List).findAll { ref ->
+                !(ref instanceof Map) || 
!externalRefTypesToStrip.contains(ref['type'] as String)
+            }
+        }
+
+        // Mix 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. See
+        // https://cyclonedx.org/docs/1.6/json/#serialNumber and PR #15614.
+        bom['serialNumber'] = ''
+        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())
+
+        logger.info('Rewrote JSON SBOM ({}) for reproducibility', projectPath)
     }
 
     private static void configureNormalization(Project project) {
         project.normalization { handler ->
             handler.runtimeClasspath {
+                // The per-module SBOM lives at META-INF/sbom.json; ignore it 
so that SBOM-only
+                // changes (e.g. dependency version bumps that flip a license 
id) do not invalidate
+                // the runtime classpath fingerprint of downstream projects.
                 it.ignore("META-INF/sbom.json")
+                // META-INF/sbom/** would only appear if Spring Boot 4 or a 
future plugin re-injected
+                // an aggregate SBOM despite disableAggregateSbomGeneration; 
keep this entry as a
+                // safety net so such a file would not silently start 
invalidating consumer caches.
+                it.ignore("META-INF/sbom/**")

Review Comment:
   This isn't the right solution



##########
build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/SbomPlugin.groovy:
##########
@@ -383,6 +436,13 @@ class SbomPlugin implements Plugin<Project> {
                                         'sbom.json'
                                     }
                                 }
+                                // Defense-in-depth against Spring Boot 4's 
CycloneDxPluginAction
+                                // (or any future Gradle plugin) 
re-introducing an aggregate SBOM at
+                                // META-INF/sbom/application.cdx.json. 
Grails-plugin jars are libraries
+                                // and ship only the per-module 
META-INF/sbom.json above; the aggregate
+                                // path under META-INF/sbom/ is intentionally 
excluded. See
+                                // disableAggregateSbomGeneration for the 
primary disable.
+                                jar.exclude('META-INF/sbom/**')

Review Comment:
   This isn't the right solution, it masks the problem



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to