From 7e1c1cb8d7722d1c224f6d2535900f7911f29823 Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Sat, 29 Aug 2026 11:36:00 +0200 Subject: [PATCH] HADOOP-19974. Publish CycloneDX 1.7 SBOMs that mark shaded dependencies Every JAR module now attaches a CycloneDX 1.7 XML SBOM under the dist profile, the profile used to deploy artifacts to Maven Central: * cyclonedx-maven-plugin (bumped to 2.9.3) keeps running makeBom under the dist profile with XML output; it can be disabled with -Dcyclonedx.skip. * A new Groovy script, dev-support/sbom/sbom.groovy, run by gmavenplus-plugin in the verify phase of the same profile, upgrades the document to CycloneDX 1.7 using cyclonedx-core-java and marks composition: ordinary dependencies get an explicit isExternal=true, while dependencies embedded by maven-shade-plugin rely on the schema default of false. When the module applies package relocation, the embedded copy is a rewritten derivative of the original artifact, so the component additionally gets a pedigree ancestor carrying the original artifact purl and SHA-256 hash; dependencies embedded verbatim keep their hash and get a notes-only pedigree documenting the embedding. The shaded artifact set is derived from the effective maven-shade-plugin configuration, so it cannot drift from the shade artifactSet, and -DskipShade builds classify everything as external, matching the jar actually produced. Hash lists are trimmed to SHA-256 and the result is re-validated against the 1.7 schema, failing the build on violations. This works around the missing maven-shade-plugin support in cyclonedx-maven-plugin; see https://github.com/CycloneDX/cyclonedx-maven-plugin/issues/472 Contains content generated by Claude Code. Assisted-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XLsffC1R992MB4Wg41qMd5 --- dev-support/sbom/sbom.groovy | 204 +++++++++++++++++++++++++++++++++++ pom.xml | 43 +++++++- 2 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 dev-support/sbom/sbom.groovy diff --git a/dev-support/sbom/sbom.groovy b/dev-support/sbom/sbom.groovy new file mode 100644 index 00000000000000..8970c23a419326 --- /dev/null +++ b/dev-support/sbom/sbom.groovy @@ -0,0 +1,204 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +/* + * Post-processes the CycloneDX SBOM produced by cyclonedx-maven-plugin + * (target/bom.xml, spec 1.6) into a CycloneDX 1.7 document: + * + * - ordinary external dependencies get an explicit `isExternal="true"` + * attribute; dependencies embedded into this jar by maven-shade-plugin + * rely on the schema default of `false`; + * - hash lists are trimmed to the SHA-256 entry to keep the document small; + * - when the module applies package relocation, each embedded component is a + * derivative of the original artifact (shade rewrites the classes), so it + * gets a `pedigree` with one ancestor describing the original standalone + * artifact and the hash computed by the plugin moves onto that ancestor: + * the checksum identifies the original artifact, not the rewritten copy. + * The purl and bom-ref stay on the embedded component so that security + * scanners can still match it against vulnerability databases; + * - when the module shades without relocations, the embedded classes are + * verbatim copies, so the component keeps its hash and gets a `pedigree` + * with only a note documenting the embedding. + * + * Relocation is detected per module, not per artifact: when any relocation is + * configured, shade also rewrites references inside classes whose own package + * is not relocated (e.g. the org.apache.hadoop classes in hadoop-client-api), + * so every embedded artifact is potentially modified. + * + * The shaded artifact set is derived from the effective Maven model (the + * maven-shade-plugin `artifactSet` of this module, profile-resolved), so it + * cannot drift from the shade configuration. With `-DskipShade` the shade + * profiles deactivate, the plugin disappears from the effective model and + * every dependency is classified external, matching the jar actually built. + * + * Runs in the `verify` phase via gmavenplus-plugin (bindings: project, + * session, log), i.e. after maven-shade-plugin but before install/deploy. + * Consequence: a plain `mvn package` leaves the unprocessed 1.6 document in + * target/bom.xml; it is rewritten before any artifact leaves the machine. + * + * Known approximation: with `` (hadoop-gcp) an included artifact + * may contribute only part of its classes, but it is still declared shaded; + * the declaration reflects the shade configuration. + */ + +import java.util.regex.Pattern +import org.apache.maven.plugin.MojoExecutionException +import org.apache.maven.plugin.MojoFailureException +import org.cyclonedx.Version +import org.cyclonedx.generators.BomGeneratorFactory +import org.cyclonedx.model.Ancestors +import org.cyclonedx.model.Bom +import org.cyclonedx.model.Component +import org.cyclonedx.model.Pedigree +import org.cyclonedx.parsers.XmlParser + +String skip = session.userProperties.getProperty('cyclonedx.skip') ?: + project.properties.getProperty('cyclonedx.skip') +if (Boolean.parseBoolean(skip)) { + log.info('SBOM post-processing skipped (cyclonedx.skip)') + return +} + +File bomFile = new File(project.build.directory, 'bom.xml') +if (!bomFile.isFile()) { + log.info("SBOM post-processing: ${bomFile} not present, nothing to do") + return +} + +// Ant-style glob on a single token, as maven-shade-plugin's SelectorUtils +// applies it to each coordinate field. +def globToPattern = { String glob -> + StringBuilder re = new StringBuilder() + glob.each { ch -> + re.append(ch == '*' ? '.*' : ch == '?' ? '.' : Pattern.quote(ch)) + } + Pattern.compile(re.toString()) +} +// maven-shade-plugin 3.6.0 ArtifactId pattern rules: +// g | g:a | g:a:classifier | g:a:type:classifier, absent fields default to *. +def parsePattern = { String p -> + String[] t = p.split(':', -1) + [g: globToPattern(t[0]), + a: globToPattern(t.length > 1 ? t[1] : '*'), + t: globToPattern(t.length > 3 ? t[2] : '*'), + c: globToPattern(t.length > 3 ? t[3] : (t.length > 2 ? t[2] : '*'))] +} +def selects = { patterns, artifact -> + patterns.any { + it.g.matcher(artifact.groupId).matches() && + it.a.matcher(artifact.artifactId).matches() && + it.t.matcher(artifact.type ?: '').matches() && + it.c.matcher(artifact.classifier ?: '').matches() + } +} + +// Derive the set of shaded artifacts from the effective model. +def shadePlugin = project.buildPlugins.find { + it.groupId == 'org.apache.maven.plugins' && it.artifactId == 'maven-shade-plugin' +} +def shadeExecution = shadePlugin?.executions?.find { it.goals.contains('shade') } +Set shadedGavs = [] as Set +boolean hasRelocations = false +if (shadeExecution != null) { + def conf = shadeExecution.configuration ?: shadePlugin.configuration + if (conf?.getChild('shadedGroupFilter') != null) { + throw new MojoExecutionException( + 'SBOM post-processing: maven-shade-plugin shadedGroupFilter is not supported') + } + hasRelocations = (conf?.getChild('relocations')?.childCount ?: 0) > 0 + def artifactSet = conf?.getChild('artifactSet') + def includes = (artifactSet?.getChild('includes')?.getChildren('include') ?: []) + .collect { parsePattern(it.value.trim()) } + def excludes = (artifactSet?.getChild('excludes')?.getChildren('exclude') ?: []) + .collect { parsePattern(it.value.trim()) } + project.artifacts.each { a -> + // ShadeMojo resolves ResolutionScope.RUNTIME (compile + runtime) and skips + // pom-type artifacts. This script runs under TEST resolution, so filter. + if (!(a.scope in ['compile', 'runtime']) || a.type == 'pom') { + return + } + if ((includes.isEmpty() || selects(includes, a)) && !selects(excludes, a)) { + shadedGavs << "${a.groupId}:${a.artifactId}:${a.baseVersion ?: a.version}".toString() + } + } + if (shadedGavs.isEmpty()) { + throw new MojoFailureException( + 'SBOM post-processing: maven-shade-plugin is active but no dependency was classified as shaded') + } +} + +// Keep only the SHA-256 entry of a hash list (null when there is none). +def sha256Only = { hashes -> + def filtered = hashes?.findAll { it.algorithm == 'SHA-256' } + filtered ? filtered : null +} + +Bom bom = new XmlParser().parse(bomFile) +bom.metadata?.toolChoice?.components?.each { it.hashes = sha256Only(it.hashes) } +Set unmatched = new HashSet<>(shadedGavs) +int shadedCount = 0 +bom.components?.each { Component c -> + String gav = "${c.group}:${c.name}:${c.version}".toString() + if (shadedGavs.contains(gav)) { + unmatched.remove(gav) + shadedCount++ + // isExternal defaults to false, so it is omitted on embedded components + if (hasRelocations) { + Component ancestor = new Component() + ancestor.type = Component.Type.LIBRARY + ancestor.group = c.group + ancestor.name = c.name + ancestor.version = c.version + ancestor.purl = c.purl + ancestor.hashes = sha256Only(c.hashes) + c.hashes = null + Ancestors ancestors = new Ancestors() + ancestors.addComponent(ancestor) + Pedigree pedigree = c.pedigree ?: new Pedigree() + pedigree.ancestors = ancestors + pedigree.notes = 'Relocated and embedded into this artifact by ' + + 'maven-shade-plugin; the ancestor component describes the ' + + 'original standalone artifact.' + c.pedigree = pedigree + } else { + c.hashes = sha256Only(c.hashes) + Pedigree pedigree = c.pedigree ?: new Pedigree() + pedigree.notes = 'Embedded verbatim into this artifact by ' + + 'maven-shade-plugin, without package relocation; the hashes ' + + 'describe the original standalone artifact.' + c.pedigree = pedigree + } + } else { + c.isExternal = Boolean.TRUE + c.hashes = sha256Only(c.hashes) + } +} +if (!unmatched.isEmpty()) { + throw new MojoFailureException( + "SBOM post-processing: shaded artifacts missing from BOM components: ${unmatched}") +} + +bomFile.setText(BomGeneratorFactory.createXml(Version.VERSION_17, bom).toXmlString(), 'UTF-8') +def errors = new XmlParser().validate(bomFile, Version.VERSION_17) +if (!errors.isEmpty()) { + throw new MojoFailureException( + "SBOM post-processing: ${bomFile} fails CycloneDX 1.7 validation:\n" + + errors.collect { it.message }.join('\n')) +} +log.info("SBOM upgraded to CycloneDX 1.7: ${shadedCount} shaded and " + + "${(bom.components?.size() ?: 0) - shadedCount} external components") diff --git a/pom.xml b/pom.xml index 5da586ab4a2a36..82bc945c94a7dd 100644 --- a/pom.xml +++ b/pom.xml @@ -119,7 +119,10 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/x 4.9.7.0 1.1.1 3.10.1 - 2.9.1 + 2.9.3 + 13.2.0 + 5.1.0 + 5.0.8 0.29.0 bash @@ -671,6 +674,23 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/x cyclonedx-maven-plugin ${cyclonedx.version} + + org.codehaus.gmavenplus + gmavenplus-plugin + ${gmavenplus-plugin.version} + + + org.apache.groovy + groovy + ${groovy.version} + + + org.cyclonedx + cyclonedx-core-java + ${cyclonedx-core-java.version} + + + @@ -865,6 +885,27 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/x xml + + + org.codehaus.gmavenplus + gmavenplus-plugin + + + sbom-postprocess + verify + + execute + + + + + + + + +