Skip to content

SOLR-17328: Add CycloneDX SBOMs to Solr binary distributions - #4690

Open
ppkarwasz wants to merge 11 commits into
apache:mainfrom
vex-generation-toolset:feat/sboms_main
Open

SOLR-17328: Add CycloneDX SBOMs to Solr binary distributions#4690
ppkarwasz wants to merge 11 commits into
apache:mainfrom
vex-generation-toolset:feat/sboms_main

Conversation

@ppkarwasz

Copy link
Copy Markdown
Member

https://issues.apache.org/jira/browse/SOLR-17328

Generates precise SBOM for the Solr binary distributions (solr-<version>.tgz and solr-<version>-slim.tgz) that include all the information
available today to the build tool:

  1. Maven artifacts included in the distribution by :solr:packaging,
  2. Vendored JavaScript libraries in solr/webapp/web/libs,
  3. NPM libraries bundled by :solr:webapp:js-client, inserted as nested assemblies of solr-js-client.
  4. Maven artifacts compiled into :solr:ui, inserted as nested assemblies of solr-ui.

The components for 1 and 4 are generated by the CycloneDX Gradle Plugin, while components from 3 are generated by @cyclonedx/cyclonedx-npm.
Vendored libraries, of course, are handled as a static list that maintainers need to keep up to date.

This is the second attempt after #3929.

Known unknowns

This PR does not attempt to manually detect the components embedded in JARs through shading or binary library inclusion.
An incomplete list of artifacts with such hidden dependencies is:

com.fasterxml.woodstox:woodstox-core:7.2.1
com.ibm.icu:icu4j:78.3
io.netty:netty-common:4.2.15.Final
io.netty:netty-tcnative-boringssl-static:2.0.79.Final
io.netty:netty-transport-native-epoll:4.2.15.Final
io.opentelemetry:opentelemetry-sdk-trace:1.56.0
org.apache.curator:curator-client:5.9.0

(The Hadoop artifacts and org.tallison.xmp:xmpcore-shaded from earlier iterations of this list no longer ship in the distribution.)

SBOM location

Each archive contains its SBOM as bom.json in the root of the distribution, next to LICENSE.txt and NOTICE.txt (see this comment on the previous PR for the full discussion):

  • The SBOM travels with the artifact, so it cannot become detached or lost. This is loosely inspired by PEP 770, which standardizes the same idea for Python wheels.
  • bom.json is one of the recognized CycloneDX filename patterns, so tools can discover it without configuration.
  • This is a Build SBOM in CISA's classification: it records the dependency information available to Gradle at build time. An Analyzed SBOM produced from the finished archive by tools such as syft or cdxgen remains a useful, complementary verification step.

There are other publication models: a solr-<version>.tgz.cdx.json sidecar next to the .asc and .sha512 files on downloads.apache.org, or embedding the SBOM in an in-toto attestation bundle together with SLSA provenance.

SBOM postprocessing

The raw output of the CycloneDX Gradle Plugin and @cyclonedx/cyclonedx-npm is postprocessed this way:

  1. Metadata: the build lifecycle phase is recorded, and the post-processing and cyclonedx-npm are listed in the tools next to the plugin.
  2. Main component: the binary release is identified by a draft sid purl (purl-spec issue #516), pkg:sid/apache.org/solr/solr@<version>?edition=full|slim, and by the Solr CPE used by the NVD.
  3. Removed components: Maven BOM/platform dependencies and the internal :platform project are stripped, they are not part of the distribution.
  4. Solr components: valid Maven purls (the CycloneDX Gradle Plugin generates incorrect Maven coordinates), description and Apache-2.0 license.
  5. Vendored JavaScript libraries: the third-party files checked into solr/webapp/web/libs are added.
  6. JavaScript client bundle: the npm packages bundled by browserify into server/solr-webapp/webapp/libs/solr/index.js are nested as subassemblies of a first-party solr-js-client component.
  7. New UI bundle: the Maven artifacts compiled into server/solr-webapp/webapp/ui are nested as subassemblies of a first-party solr-ui component, from a child SBOM generated in :solr:ui over wasmJsRuntimeClasspath, together with the @js-joda/core npm package bundled by the Kotlin toolchain.
  8. Archive locations: the assembled distribution directories are scanned, and files are matched to components by SHA-256 hash, recorded as evidence.occurrences. This also proves that each vendored JavaScript library ships unmodified.
  9. Hashes: only the SHA-256 hash of each component is kept; the plugin emits eight algorithms per artifact, which only adds bulk. The npm integrity hashes on externalReferences (SHA-512 of the registry tarballs) are preserved.

This change was developed with significant use of an AI coding assistant (Claude), with human review of every step; commits carry an Assisted-By trailer.

Checklist

Please review the following and check all that apply:

  • I have reviewed the guidelines for How to Contribute and my code conforms to the standards described there to the best of my ability.
  • I have created a Jira issue and added the issue ID to my pull request title.
  • I have given Solr maintainers access to contribute to my PR branch. (optional but recommended, not available for branches on forks living under an organisation)
  • I have developed this patch against the main branch.
  • I have run ./gradlew precommit; the full ./gradlew check test suite was not run for this build-only change.
  • I have verified the change by generating both SBOMs and validating them (graph integrity at every nesting level, occurrence coverage, SHA-256 hashes, opt-out builds, up-to-date re-runs); there is no unit-testable production code.
  • I have added documentation for the Reference Guide (the shipped bom.json could be documented in a follow-up)
  • I have added a changelog entry for my change

Each binary distribution (full and slim) now ships a CycloneDX 1.6
bom.json describing its actual contents. Two resolvable configurations
in :solr:packaging (bomFull, bomSlim) mirror the distribution assembly
and are rendered by the CycloneDX Gradle plugin, then a post-processing
step adjusts the result:

* The metadata declares the "build" lifecycle phase (a CISA Build SBOM)
  and lists the post-processing and cyclonedx-npm next to the plugin in
  the tools.
* The main component identifies the binary release with a draft "sid"
  purl (purl-spec issue apache#516, pkg:sid/apache.org/solr/solr@<version>
  with an edition qualifier) and the Solr CPE used by the NVD.
* Maven BOM/platform dependencies and the internal :platform project,
  which are not part of the distribution, are stripped.
* Solr project components get valid Maven purls (artifactId instead of
  the Gradle project name, with the ASF snapshots repository for
  snapshot builds), a description and the Apache-2.0 license.
* The UI artifacts inside the webapp are covered: the npm packages
  bundled by browserify into the OpenAPI JS client and the Maven
  artifacts compiled into the wasmJs UI are nested as subassemblies of
  first-party solr-js-client and solr-ui components, from child SBOMs
  generated in :solr:webapp:js-client (official cyclonedx-npm tool,
  runtime dependencies only) and :solr:ui (plugin task over
  wasmJsRuntimeClasspath). The vendored JavaScript libraries of the
  AngularJS admin UI are listed from a curated list.
* The location of every JAR and JavaScript file in the distribution is
  recorded as evidence.occurrences by matching SHA-256 hashes against
  the assembled directories, which also proves the vendored libraries
  ship unmodified.
* Only the SHA-256 hash of each component is kept; the plugin emits
  eight algorithms per artifact, which only adds bulk.

The BOM configurations resolve strictly with the JVM runtime attributes
(the packaging project applies jvm-ecosystem for the attribute schema),
so BOM-managed and variant-aware dependencies resolve like a runtime
classpath instead of silently disappearing. bomFull resolves
consistently with bomSlim, matching the distribution layout where
server libraries win over module-pulled versions. The child SBOMs
degrade gracefully when the UI projects are disabled with
-PdisableJsClient / -PdisableUiModule.

Assisted-By: Claude Fable 5 <noreply@anthropic.com>

@epugh epugh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels like there is a lot of boilerplate-ish comments. Do we need all of them? Maybe the more verbose pattern is something we want to adopt?

Comment thread gradle/libs.versions.toml
Comment thread solr/packaging/build.gradle Outdated
Comment thread solr/packaging/build.gradle Outdated
Review feedback on apache#4690: the CycloneDX BOM configurations, the two
generator tasks and the post-processing filled roughly 480 of the 873
lines of solr/packaging/build.gradle, a file otherwise concerned with
assembling the distribution archives. They now live in their own script
plugin, applied from the root build next to gradle/solr/packaging.gradle.

The move is verbatim except for the CycloneDX types. A script plugin is
not compiled against the plugins block classpath of the script that
applies it, so CyclonedxDirectTask and Component.Type are looked up by
name through the buildscript class loader instead of being imported.

What stays in :solr:packaging is the wiring that depends on tasks the
distribution plugin only creates later: the bom.json entries in the
distribution contents and the dependsOn 'cyclonedx' declarations.

Both SBOMs are byte-identical to the ones generated before the move,
apart from the per-run serial number and timestamp.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bomFull and bomSlim configurations exist only on this branch, so
changes on main that alter what :solr:packaging resolves do not update
their entries in solr/packaging/gradle.lockfile. Two such changes had
accumulated and broke the build:

* SOLR-18187 (apache#4259) added the anthropic, google-ai-gemini and ollama
  langchain4j providers to :solr:modules:language-models, which bomFull
  pulls in transitively.
* apache#4594 bumped the OpenTelemetry and Prometheus stacks.

Regenerated with "gradlew resolveAndLockAll collectJarInfos
--write-locks"; only the packaging lockfile changed.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback on apache#4690: the third-party JavaScript under
solr/webapp/web/libs was described by a hard-coded list in the SBOM
code, which nothing tied to the directory it described, so a file could
be added, bumped or removed and the SBOM would keep reporting the old
state. Three of the sixteen files also had no purl at all, leaving them
invisible to vulnerability scanners.

The components now live in solr/webapp/vendored-libs.json as CycloneDX
component objects, merged into the distribution SBOM as they are, and
:solr:webapp:validateVendoredLibs fails whenever the manifest and the
directory disagree. The generator tasks depend on it, so a stale hash
cannot reach a BOM, which also matters for the archive locations of
step 8 since those are matched by hash. updateVendoredLibs refreshes
the hashes; it cannot invent identity or pedigree, so a new file keeps
failing validation until those are filled in by hand.

Every component now identifies its upstream package by purl, even where
the shipped bytes deviate, so that advisories still match, and records
the deviation under "pedigree" with the original in pedigree.ancestors.
Comparing each file against upstream established that:

* eleven are the pristine upstream artifact with an Apache licence
  header prepended, and the ancestors carry the verified upstream
  SHA-256 next to a distribution reference;
* ui-grid.min.js is byte-identical to npm angular-ui-grid 4.10.0 and so
  needs no pedigree at all;
* ngtimeago.js is a fork of uttesh/ngtimeago pinned to the untagged
  commit 0b1e72785a6e, patched by SOLR-7780, LUCENE-6732 and
  SOLR-13343, which pedigree.commits now records. It is unrelated to
  the npm package ng-timeago;
* jquery-ui.min.js, highlight.js, angular-chosen.min.js and
  jssha-3.3.1-sha256.min.js are custom or differently built artifacts
  that match no published file; they keep the upstream purl so that
  advisories are not missed, with the deviation in pedigree.notes.

d3.js and angular-utf8-base64.min.js are served by neither unpkg nor
cdnjs under those names; the former is d3.v2.js inside the d3 2.8.1
tarball and the latter exists only in the git repository, hence its
pkg:github purl.

Both BOMs validate against the CycloneDX 1.6 schema. Apart from the
three components that gain a purl, and the pedigree entries, the
generated SBOMs are unchanged.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ppkarwasz
ppkarwasz requested a review from epugh August 5, 2026 22:05
Follow-up to 53981c1, which described the blocks prepended to the
files under solr/webapp/web/libs as Apache licence headers. They are
not: each is the upstream project's own licence text carrying the
upstream copyright holder, MIT for the AngularJS, jQuery, Chosen,
jsTree and ngtimeago files and BSD-3-Clause for d3. The pedigree notes
now say which licence text was prepended, without asserting anything
about who wrote it.

The one file that does carry Apache-2.0 text is
angular-utf8-base64.min.js, and that is not the ASF's either: the
library encapsulates Vassilis Petroulias's base64.js, released by its
own author under Apache-2.0, whose notice the vendoring reproduces.
Since the shipped code is therefore part MIT and part Apache-2.0, its
licence becomes the SPDX expression "MIT AND Apache-2.0".

Attribution moves out of prose and into the fields meant for it. Every
component now carries a manufacturer, which is what satisfies the
Component Producer element of the 2026 CISA SBOM Minimum Elements; that
element replaced the 2021 NTIA Supplier Name and asks for the original
project or maintaining organization. Individually authored libraries
also list their authors. The copyright field is dropped: it is not a
minimum element under either the 2021 or the 2026 guidance, and the
attribution it held is now carried by manufacturer and authors.

highlight.js records its version as "unknown" rather than omitting it.
Component Version is a required element, and the guidance asks the SBOM
author to state explicitly that information is unknown instead of
leaving it out. The file carries no version marker and its API predates
highlight.js 8.0.

All producer URLs use https, and each was checked to resolve. Two could
not simply change scheme: angular-ui.github.com serves no https, so
ui-grid points at its GitHub project, and getharvest.com redirects.

Both BOMs still validate against the CycloneDX 1.6 schema.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ppkarwasz

Copy link
Copy Markdown
Member Author

Yesterday's Apache Trusted Releases presentation made me reconsider the "SBOM location" decision above.

Rather than shipping bom.json inside solr-11.0.0.tgz, I'd now lean towards a solr-11.0.0.tgz.cdx.json sidecar next to it on downloads.apache.org, matching the .asc/.sha512 pattern. This is the model ATR already supports: e.g. this test catalog page, where each artifact lists Download / Signature / Checksum / SBOM as siblings.

This does give up the two properties I argued for earlier (the SBOM travelling with the archive so it can't get detached, and being auto-discoverable via the recognized CycloneDX filename patterns inside the archive) in exchange for consistency with how ATR expects to publish and surface SBOMs alongside the rest of the release metadata.

Open to keeping both if that's not considered redundant: sidecar for ATR/downloads.apache.org discovery, bom.json retained inside the archive for anyone working from a downloaded copy.

Each module's lib/ directory is assembled from a copyRecursive() of its
runtimeLibs configuration. A copied Gradle configuration inherits its
source's dependencies and excludes but not its shouldResolveConsistentlyWith
alignment and not its lock state, so the alignWithRuntimeClasspath call a
few lines above, whose comment promises "the distribution contains the same
library versions that were tested", was silently discarded for the copy that
gets packaged.

The subtraction that follows removes the platform libraries by file, so an
unaligned copy that resolved a different version of a library the platform
also ships was not recognised as a duplicate and stayed. Five such jars
reached the binary distribution:

  cross-dc-manager/lib/error_prone_annotations-2.49.0.jar
  modules/cuvs/lib/lucene-backward-codecs-10.2.0.jar
  modules/gcs-repository/lib/jackson-annotations-2.18.3.jar
  modules/gcs-repository/lib/jackson-core-2.18.3.jar
  modules/gcs-repository/lib/jackson-databind-2.18.3.jar

They were not the versions the tests ran against, they were absent from the
lock state, and they were the only five of the 188 third-party jars under
the module lib directories with no solr/licenses entry, so validateJarChecksums
never saw them either. The error_prone_annotations one was never a runtime
dependency at all: 2.49.0 is locked only for the annotationProcessor and
errorprone configurations.

Aligning the copy makes both sides of the subtraction resolve to the same
file, so all five are now recognised as duplicates of the platform's copies
and dropped. Nothing is added: consistent resolution only raises versions of
modules already in the graph.

Runtime behaviour does not change. SolrResourceLoader appends module lib URLs
after the existing ones and the WEB-INF/lib jars sit in the parent loader, so
the platform version already won; the five jars were dead weight that only
file-level scanners could see.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gap fixed in the previous commit was invisible to the build: the
distribution shipped five jars that no BOM component described, and nothing
noticed. Compare with a file-level scan of the tarball and they show up
immediately.

Step 8 of the post-processing already indexes every file in the assembled
distribution by SHA-256 in order to record evidence.occurrences, so the
leftovers of that match are free. Any jar whose hash matches no component
now fails the generator task, naming the paths.

The check is scoped to jars. The JavaScript client bundle and the wasm UI
bundle are single files assembled from many components, so their occurrences
are recorded by hand in steps 6 and 7 rather than matched by hash.

Verified by reverting the packaging fix and rerunning cyclonedxFull, which
fails with exactly those five paths.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ppkarwasz

Copy link
Copy Markdown
Member Author

e96844d introduces a change to the built package: it removes 5 JARs from modules that were duplicate and already existed in the server/solr-webapp.

@ppkarwasz

ppkarwasz commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

I cross-checked the SBOM this PR generates against three external scanners, all on the same solr-11.0.0-SNAPSHOT.tgz.

this PR (bom-full.json) Syft 1.46 cdxgen 11 -t jar cdxgen 11 -t gradle
components 500 736 346 909
dependency edges 501 7 0 948
licences 500 / 500 286 / 376 0 0
SHA-256 hashes 454 on paired file components 0 0
file locations (evidence.occurrences) 364 0 0 0
npm, vendored JS, wasm 149 none none klibs only

Where they overlap they agree: 346 of Syft's maven coordinates and 416 of cdxgen's match ours exactly. The differences are scope, not disagreement.

What the scanners add: shaded dependencies read from embedded META-INF/maven/**/pom.properties. We could pick those up later; Syft also emits per-component CPEs, which we do not.

What they miss: the entire Admin UI supply chain. Both cdxgen modes and Syft are Java-only here, so the 44 npm packages browserified into libs/solr/index.js, the 15 vendored JavaScript files and the wasm UI are invisible to them. That is the part of this PR no scanner replicates.

Caveats on the tools.

  • cdxgen -t jar derives coordinates from filenames and manifests, so 32 of its 35 extra entries are mis-parses of components we already have (solr-core-11.0.0-SNAPSHOT@11.0.0-SNAPSHOT, scala-library@2.13.18.v20251115-142856-VFINAL-98f40d0); only 3 were real.

  • cdxgen -t gradle inventories the whole build rather than the distribution: it includes ecj, javacc and junit, 70 unshipped Compose desktop variants, and 110 artifacts at more than one version because it records requested rather than resolved versions.

The full distribution builds on the slim one with
"with(distributions.slim.getContents())", which brought in the slim BOM as
bom.json, and the full BOM was then added under the same name with
DuplicatesStrategy.INCLUDE. The archive therefore carried two bom.json
entries, 588 KB and 938 KB, and which one landed on disk was up to the
extraction tool: GNU tar overwrites and so happens to leave the correct
full BOM, but a tool that stops at the first match would silently give the
slim BOM for the full distribution.

Lift the shared content into a "commonDistContents" copy spec that holds no
BOM, so each distribution adds only its own. Verified to be a no-op
otherwise: the full archive listing goes from 2042 to 2041 entries with the
duplicate as the only difference, and the slim listing is unchanged down to
the file modes and sizes.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each distribution already carries its BOM as bom.json in the archive root,
which keeps the SBOM with the artifact it describes but means anyone who
only wants the SBOM has to download the whole distribution to reach it.
Publish the same file next to the archive as well, named after it:

  solr-<version>.tgz.cdx.json
  solr-<version>-slim.tgz.cdx.json

with a checksum and, when signing is enabled, a signature, like every other
release artifact. The two files are exposed as separate artifacts of a new
"sbom" configuration rather than as a directory, so that computeChecksums
and the signing task see each of them.

No SBOM is published for the source release: assembleSourceTgz is a raw
"git archive" export, so there is no resolved dependency set for a
build-time SBOM to describe.

smokeTestRelease.py needs a matching change. checkSigs() walked a sorted
listing and treated every entry starting with "<artifact>." as one of its
signatures, which the new files break twice over: solr-X.tgz.cdx.json
merely starts with solr-X.tgz., and its own .sha512 sorts between the
archive and the archive's .sha512, so the archive's signatures are no
longer adjacent to it. An entry is now recognised as a signature only when
stripping .asc or .sha512 leaves the name of another entry, which drops the
dependency on listing order altogether.

Verified against a locally assembled release folder, signed with a
throwaway key: each sibling is byte-identical to the bom.json inside the
corresponding archive, every checksum and signature verifies, checkSigs()
passes end to end, and both files validate against the CycloneDX 1.6
schema.

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ssing

The closing parenthesis was misplaced, so the tuple was passed to
RuntimeError as a second argument and the format string was left with one
argument for two placeholders. Rather than reporting the missing file,
testOpenApi raised "TypeError: not enough arguments for format string".

Assisted-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation scripts labels Aug 6, 2026
@ppkarwasz

Copy link
Copy Markdown
Member Author

Rather than shipping bom.json inside solr-11.0.0.tgz, I'd now lean towards a solr-11.0.0.tgz.cdx.json sidecar next to it on downloads.apache.org, matching the .asc/.sha512 pattern. This is the model ATR already supports: e.g. this test catalog page, where each artifact lists Download / Signature / Checksum / SBOM as siblings.

This was implemented in 89dd020.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

admin-ui dependencies Dependency upgrades documentation Improvements or additions to documentation scripts tool:build

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants