Skip to main content

Spring Boot SBOM in Practice: tools jar-mode SBOM Command, CycloneDX and Dependency Scanning

Generate a CycloneDX SBOM in a Spring Boot jar, read it with the new Boot 4.2 tools jar-mode sbom command and Actuator, scan it with OSV and check its licences. Run on 4.2.0-RC1, with real output.

Somebody asks, during a security incident, “which of our services contain log4j 2.14.1?” If the honest answer is “give us a day to unpack the jars”, the problem is not log4j. A software bill of materials (SBOM) is the fix: a machine-readable list of everything inside a build, produced once when you build and kept next to the artifact. Spring Boot can already put one inside your jar. Boot 4.2 adds a command to get it back out again, java -Djarmode=tools -jar app.jar sbom. This page builds a small application, generates its SBOM, reads it three ways (the new command, an Actuator endpoint and plain unzip), then does the two things people actually want an SBOM for: scanning it for known vulnerabilities and checking its licences. It also shows where the document and the jar disagree, which turned out to be the most useful thing I found.
Tested with: Spring Boot 4.2.0-RC1, a release candidate. The latest GA release is 4.1.2, and the sbom command described here is not in it: I listed the command classes in both jars and only 4.2.0-RC1 has SbomCommand (output/11-jarmode-commands-by-version.txt). Everything else on this page, the CycloneDX document and the Actuator endpoint, is not new. Also: Spring Framework 7.1.0-RC1, cyclonedx-maven-plugin 2.9.3 (picked by the Boot parent POM), CycloneDX spec 1.6, JDK 25.0.5, Maven 3.9.11. Versions from the Maven Central directory index on 2026-10-24. The code is in spring-boot-demo/sbom; scripts/run-all.sh regenerates every output/NN-*.txt file quoted below. No Docker and no API keys; the vulnerability scan needs outbound HTTPS to api.osv.dev.
Read it in order. The first sections explain what an SBOM is and how to get one into a jar; the middle sections read and check it; the later sections scan it, check licences, and list what goes quietly wrong.

An SBOM is an ingredients list for your jar

A Spring Boot application is a jar full of other people’s jars. A small web application with Actuator has 42 of them, and each one brings a name, a version and a licence you did not choose individually. An SBOM writes all of that down: for each component, its group, name and version, a package URL (a standard way to name a library, like pkg:maven/org.slf4j/[email protected]), file hashes, licences, and which component needs which. Once the list exists, three jobs become simple, because they work from the list instead of from the jar. A scanner can ask a vulnerability database about every entry. A licence check can read the licence field. And when the next famous vulnerability arrives, you can search the lists you already have instead of unpacking artifacts.
sbom-1.0.0.jaryour classes and42 library jarsSBOMa list of 49 components:name, version, hashes,licences, who needs whomVulnerability scanis anything on the list known bad?Licence checkcan we ship all of it?Incident responsewhich services run log4j 2.14.1?The SBOM is a separate document that travels with the jar. Everything on the right works from the list alone,without unpacking the jar or knowing how it was built.
The diagram is the whole idea: the SBOM is a separate file that can be copied, stored and searched. The rest of this page is about producing a trustworthy one and not mistaking it for more than it is.

Put the SBOM inside the jar at build time

There are two ways to get an SBOM. You can generate it later by pointing a tool at a finished jar or container image, which is what scanners do. Or you can generate it at build time from the dependency information the build already has, which is what this page does. Build time is cheaper and more precise, because Maven knows exactly which versions it resolved and why. The Spring Boot parent POM already configures the CycloneDX Maven plugin, so enabling it is a bare declaration, as the Boot how-to guide describes. From pom.xml:
<plugin>
  <groupId>org.cyclonedx</groupId>
  <artifactId>cyclonedx-maven-plugin</artifactId>
</plugin>
That is all. The parent supplies the version (2.9.3 here), runs the plugin in the generate-resources phase, and writes the document to META-INF/sbom/application.cdx.json inside the compiled classes, so it ends up inside the jar. I first wrote all of that configuration out by hand, then removed it and re-ran everything: every captured output file was identical, so the declaration really is all you need.
pom.xmlyour dependenciescyclonedx-maven-pluginphase generate-resourcestarget/classes/META-INF/sbom/application.cdx.jsonsbom-1.0.0.jar (after repackage)the document inside, and in the manifest:Sbom-Location, Sbom-Formatjar mode: … sbomprints or saves it/actuator/sbomserves it over HTTPunzip -p app.jar PATHor any tool that opens a zip
The figure shows the path and the three ways out, which the next sections take in turn. One detail is easy to miss: the jar manifest gains two lines that point at the document. Here are the relevant manifest lines for a build with the SBOM and, for contrast, one built with the plugin switched off (-Dcyclonedx.skip=true), both from output/03-sbom-command.txt and output/02-no-sbom.txt:
$ unzip -p sbom-1.0.0.jar META-INF/MANIFEST.MF | grep -i sbom
Implementation-Title: sbom
Start-Class: com.ankurm.sbom.SbomApplication
Sbom-Location: META-INF/sbom/application.cdx.json
Sbom-Format: CycloneDX
$ unzip -p sbom-without.jar META-INF/MANIFEST.MF | grep -i sbom
Implementation-Title: sbom
Start-Class: com.ankurm.sbom.SbomApplication

Read it back with the new jar-mode command

Before 4.2 you got the document out of a jar with unzip -p and a path you had to know. The tools jar mode, the same one that offers extract and list-layers for container builds, now has a third command. Help output from output/01-jarmode-help.txt:
$ java -Djarmode=tools -jar sbom-1.0.0.jar help sbom
Print the SBOM from the jar

Usage:
  java -Djarmode=tools -jar sbom-1.0.0.jar sbom [options]

Options:
  --destination string  File to write the SBOM to. Defaults to printing the SBOM to the console
It reads the manifest to find the document, so you do not need to know the path. Run it with no options and it prints the document; add --destination and it writes a file instead. I ran both, plus unzip on the known path, and compared the results (output/03-sbom-command.txt):
exit code of 'sbom': 0
$ java -Djarmode=tools -jar sbom-1.0.0.jar sbom   (first 120 characters)
{
  "bomFormat" : "CycloneDX",
  "specVersion" : "1.6",
  "serialNumber" : "urn:uuid:268f551b-ceaa-3a72-8f92-245b35596fa
--destination exit code: 0
bytes: command 127460, --destination 127460, unzip 127460
SHA-256 of the three files: 1 distinct value(s)
All three produce exactly the same bytes, 127,460 of them, and the three SHA-256 values collapse to one. So the command is a convenience, not a different view: it gives you the document the build wrote, unchanged. When the jar has no SBOM, the command says so, and the exit code matters for scripts. Built with the plugin skipped, from output/02-no-sbom.txt:
$ java -Djarmode=tools -jar sbom-without.jar sbom
Error: No SBOM found in the jar; the manifest has no 'Sbom-Location' attribute

exit code: 1
It fails with exit code 1 and a message that names the missing manifest attribute. That is the behaviour you want in a pipeline: a build that was supposed to produce an SBOM and did not will stop there.
Going deeper: what else is in jar mode, and what I did not test The same jar mode also has extract and list-layers, which matter for container builds (the Docker images article covers layered jars). A CI step that publishes the SBOM next to the artifact is now one line instead of a path hunt. The Boot documentation shows a Gradle setup with the org.cyclonedx.bom plugin; I only built with Maven, so I make no claim about what the Gradle jar looks like. Because 4.2 is still a release candidate, the command’s options and messages could change before GA. The class list for both versions is in output/11-jarmode-commands-by-version.txt:
4.1.2: Command ExtractCommand ExtractLayersCommand HelpCommand ListCommand ListLayersCommand 
4.2.0-RC1: Command ExtractCommand ExtractLayersCommand HelpCommand ListCommand ListLayersCommand SbomCommand 

What is inside the document

A CycloneDX file is plain JSON, and you do not need a special tool to look inside it. The small script scripts/analyse.py reads it with the standard library only. Its inspect mode prints the facts that matter (output/04-inspect.txt):
format CycloneDX 1.6, serial urn:uuid:268f...
lifecycle phase: build
generated by: org.cyclonedx:cyclonedx-maven-plugin 2.9.3
describes: application com.ankurm:sbom:1.0.0
components: 49
by scope: required 49
with a hash: 49 of 49
hash algorithms on the first component: MD5, SHA-1, SHA-256, SHA-512, SHA-384, SHA3-384, SHA3-256, SHA3-512
with a licence: 49 of 49
Three things stand out. Every one of the 49 components has hashes (eight algorithms for the first one) and a licence, because the plugin got them from the artifacts and their POMs. The document records that it was produced in the build lifecycle phase, by this plugin at this version, which is the provenance a reader needs to decide how far to trust it. And the dependency graph is part of the file:
dependency entries: 50
direct dependencies of the application: 2
  org.springframework.boot/[email protected]
  org.springframework.boot/[email protected]
Your application has two direct dependencies, the two starters you declared, and the other 47 components are reached through them. That is what lets you answer “why do we have this library?” later: follow the dependsOn edges from the component back to a starter.

Does the SBOM match the jar?

Here is the question that decides how much to trust the document. The SBOM says 49 components. The jar’s BOOT-INF/lib directory holds 42 jar files. If the document describes the jar, the two lists should be the same. output/07-sbom-against-jar.txt compares them by file name:
components in the SBOM: 49
jars in BOOT-INF/lib:   42
in both:                41
in the SBOM but not in the jar:
  spring-boot-starter-4.2.0-RC1.jar
  spring-boot-starter-actuator-4.2.0-RC1.jar
  spring-boot-starter-jackson-4.2.0-RC1.jar
  spring-boot-starter-logging-4.2.0-RC1.jar
  spring-boot-starter-micrometer-metrics-4.2.0-RC1.jar
  spring-boot-starter-tomcat-4.2.0-RC1.jar
  spring-boot-starter-tomcat-runtime-4.2.0-RC1.jar
  spring-boot-starter-webmvc-4.2.0-RC1.jar
in the jar but not in the SBOM:
  spring-boot-jarmode-tools-4.2.0-RC1.jar
The SBOM and the jar describe almost, but not exactly, the same setSBOM41 in both8Jar (lib)41 in both18 only in the SBOM: the eight spring-boot-starter-* artifacts1 only in the jar: spring-boot-jarmode-tools, the library that implements the commandEach block is one jar file. The SBOM is accurate about what Maven resolved, which is not quite the same as what is in the jar.
Forty-one match. The eight extras are all spring-boot-starter-* artifacts: they are real dependencies, so Maven lists them, but they are not in the jar’s lib directory. I did not trace why the repackager leaves them out, and for a scan it hardly matters, since they are dependency-only artifacts. The one the other way round is more interesting: spring-boot-jarmode-tools is in the jar and not in the SBOM. It is the library that makes the new command work, and it is added when the jar is repackaged, after the SBOM was written. My reading is that a build-time SBOM cannot see anything added at packaging time, which is a general property rather than a Spring quirk.
So what is the SBOM? A faithful record of what the build resolved, not an inventory taken from the finished file. For a vulnerability scan that is nearly always what you want. If you need a statement about the exact bytes in a container image, with OS packages and everything the packaging added, a scanner that opens the image is a different and complementary tool.

Serve it from Actuator

For a running service, the sbom Actuator endpoint serves the same document over HTTP, so a central tool can collect SBOMs from every instance without access to the build. Two facts matter. It is off by default over HTTP, so it must be named in the exposure list. From src/main/resources/application.properties:
management.endpoints.web.exposure.include=health,sbom
And it needed no location property at all to find the document in either the test or the jar. The test src/test/java/com/ankurm/sbom/ActuatorSbomTest.java starts the application and calls the endpoint; its output is output/05-actuator-sbom.txt:
GET /actuator              -> links: self, sbom, sbom-id, health, health-path
GET /actuator/sbom         -> 200 {"ids":["application"]}
GET /actuator/sbom/application -> 200, content-type application/vnd.cyclonedx+json
  bomFormat CycloneDX, specVersion 1.6, components 49
same document as the file the build wrote: true
GET /actuator/sbom/nope    -> 404
The index lists one SBOM called application, the document comes back with the CycloneDX media type, and it is equal, as parsed JSON, to the file the build wrote. Without the exposure line you get the answer in output/06-actuator-sbom-not-exposed.txt, from src/test/java/com/ankurm/sbom/DefaultExposureTest.java:
management.endpoints.web.exposure.include=health
GET /actuator/sbom -> 404
Treat the endpoint as internal. The document lists every library and exact version you run, which is a very good start for someone planning an attack. Expose it on a management port or behind authentication, as the Actuator in production article describes, not on the public interface.

Scan it for known vulnerabilities

A vulnerability scanner takes each component’s identity and asks a database whether that exact version has known advisories. The identity is the package URL, and the SBOM already has it for every component. Mature tools such as Trivy and Grype read CycloneDX files directly. I could not run either: in my sandbox the release downloads returned 403. The commands in their documentation are trivy sbom app.cdx.json and grype sbom:app.cdx.json, and I have not verified them. What I could reach was the public OSV database. scripts/analyse.py sends every package URL in one batch request to api.osv.dev, then fetches the details of each advisory it reports:
def scan(p):
    d = load(p)
    comps = d["components"]
    res = post("https://api.osv.dev/v1/querybatch",
               {"queries": [{"package": {"purl": purl(x).split("?")[0]}} for x in comps]})["results"]
    hits = [(x, r["vulns"]) for x, r in zip(comps, res) if r.get("vulns")]
    print(f"queried {len(comps)} components against api.osv.dev")
    print(f"components with at least one advisory: {len(hits)}")
    for x, vs in hits:
        print(f"{x['group']}:{x['name']}:{x['version']} - {len(vs)} advisories")
        for v in sorted(vs, key=lambda v: v["id"]):
            full = get("https://api.osv.dev/v1/vulns/" + v["id"])
            cves = [a for a in full.get("aliases", []) if a.startswith("CVE-")]
            fixed = sorted({e["fixed"] for a in full.get("affected", []) for r in a.get("ranges", [])
                            for e in r.get("events", []) if "fixed" in e})
            print(f"  {v['id']} {','.join(cves) or '-'} fixed in {','.join(fixed) or '?'}: {full.get('summary', '')[:70]}")
First, the application itself, from output/09-osv-scan-clean.txt:
scanned: 2026-10-24
queried 49 components against api.osv.dev
components with at least one advisory: 0
Forty-nine components and no advisories, on that date. To see what a hit looks like, the pom.xml has a vulnerable profile that adds log4j-core 2.14.1, the version that Log4Shell made famous. It must never be used for real. The SBOM of that build, scanned the same way, is output/10-osv-scan-vulnerable.txt:
scanned: 2026-10-24
queried 50 components against api.osv.dev
components with at least one advisory: 1
org.apache.logging.log4j:log4j-core:2.14.1 - 7 advisories
  GHSA-3pxv-7cmr-fjr4 CVE-2026-34480 fixed in 2.25.4: Apache Log4j Core: Silent log event loss in XmlLayout due to unescaped
  GHSA-6hg6-v5c8-fphq CVE-2026-34477 fixed in 2.25.4: Apache Log4j Core: `verifyHostName` attribute silently ignored in TLS 
  GHSA-7rjr-3q55-vv33 CVE-2021-45046 fixed in 1.10.8,1.11.11,1.9.2,2.0.12,2.12.2,2.16.0,2.3.1: Incomplete fix for Apache Log4j vulnerability
  GHSA-8489-44mv-ggj8 CVE-2021-44832 fixed in 1.10.9,1.11.13,1.9.2,2.0.14,2.12.4,2.17.1,2.3.2: Improper Input Validation and Injection in Apache Log4j2
  GHSA-jfh8-c2jp-5v3q CVE-2021-44228 fixed in 1.10.8,1.11.10,1.9.2,2.0.11,2.12.2,2.15.0,2.3.1: Remote code injection in Log4j
  GHSA-p6xc-xr62-6r2g CVE-2021-45105 fixed in 1.10.9,1.11.12,1.9.2,2.0.13,2.12.3,2.17.0,2.3.1: Apache Log4j2 vulnerable to Improper Input Validation and Uncontrolled
  GHSA-vc5p-v9hr-52mj CVE-2025-68161 fixed in 2.25.3: Apache Log4j does not verify the TLS hostname in its Socket Appender
One new component and 50 instead of 49 queried, and the scan reports seven advisories for it, including CVE-2021-44228. Note the “fixed in” column: for each advisory it names the releases that contain the fix, and the one you need is the newest, which is where to start when you plan the upgrade. The clean result deserves a caution. An SBOM is a snapshot; the vulnerability database is not. The file you generated today can have advisories tomorrow without changing a byte. That is why SBOMs are worth storing: re-scan the stored files on a schedule, and when a new advisory lands you can search every service you ever shipped. The script prints results and always exits with code 0; wiring a threshold into your pipeline is yours to decide.

Check the licences

The same document carries licences, and a licence check is a dozen lines of code. The trap is in how you write the rule. output/08-licences.txt:
licence entries (a component with two licences counts once under each):
  Apache-2.0                           43
  EPL-2.0                              3
  LGPL-2.1-only                        2
  MIT                                  2
  GPL-2.0-with-classpath-exception     1
  CC0-1.0                              1
  BSD-2-Clause                         1

naive rule 'fail on any GPL or LGPL entry' flags 3: logback-classic, logback-core, jakarta.annotation-api
rule 'fail only when every listed licence is GPL or LGPL' flags 0
no licence listed: none
dual-licensed components (more than one entry):
  logback-classic: EPL-2.0 / LGPL-2.1-only
  logback-core: EPL-2.0 / LGPL-2.1-only
  jakarta.annotation-api: EPL-2.0 / GPL-2.0-with-classpath-exception
  HdrHistogram: CC0-1.0 / BSD-2-Clause
The obvious rule, “fail on any GPL or LGPL entry”, flags three components in an ordinary Spring Boot starter set. They are not violations: logback and jakarta.annotation-api are each offered under two licences, and the document lists both as separate entries. As far as I know, logback is dual-licensed EPL-2.0 or LGPL-2.1, so you may pick the EPL. The CycloneDX file by itself does not say whether two entries mean “and” or “or”, so a check that reads only the list should either be told which licences your policy accepts, or flag these for a human instead of failing the build. The stricter rule in the output, “fail only when every listed licence is a problem”, flags none.

Five ways SBOM work goes quietly wrong

1. The build did not produce an SBOM, and nobody noticed Forgetting the plugin declaration builds a perfectly good jar with no SBOM in it. The runtime does not care. Only the consumer notices, when it asks. The jar-mode command exits with code 1 (output/02-no-sbom.txt), so a pipeline step that runs it right after the build is an inexpensive guard.
2. The endpoint is not exposed, or exposed too widely Both are one property away. With default exposure it is a 404 (output/06-actuator-sbom-not-exposed.txt). Exposed on the public port it hands out your dependency list. Decide on purpose, and test the choice.
3. The SBOM is not the jar Eight starters in the document are not in the jar, and a jar-mode library in the jar is not in the document (output/07-sbom-against-jar.txt). Harmless here, but it is the reason to describe an SBOM as “what the build resolved” and to add an image scan if you need the exact contents of a container.
4. A licence rule that fails on dual licences Described above. Three false positives in a starter set with 49 components is enough to make a team switch the check off, which is worse than a rule that is slightly too lenient and still on.
5. Scanning once and calling it done The clean scan in output/09-osv-scan-clean.txt carries a date on its first line for a reason. Advisories are published after you ship. Keep the SBOM with the artifact and scan it again on a schedule.

Should you do this?

Yes, generating the SBOM is nearly free; be selective about the rest. Adding the plugin declaration costs a few lines, and you will be glad of the files the day an advisory lands. Storing them with each release is the cheap and valuable part. Be more careful with the other pieces. The jar-mode sbom command is new in a release candidate, so use it in scripts only if you accept that its behaviour may still change before 4.2 GA, or stay on unzip -p until then. Exposing the Actuator endpoint is a decision about your attack surface, not a default. And an SBOM scan finds known issues in declared dependencies: it does not tell you whether you call the vulnerable code, it does not see what a container image adds, and a clean result is a statement about today. If a customer or regulator asks for an SBOM, check which format and which fields they expect before you hand over the file, because this page tested only CycloneDX 1.6 in JSON.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.