diff --git a/README.md b/README.md
index b9504b8..c21d707 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ files.
| Directory | Article | What it demonstrates |
|---|---|---|
+| [`sbom/`](sbom) | [Spring Boot SBOM in Practice: the tools jar-mode sbom Command, CycloneDX and Dependency Scanning](https://ankurm.com/spring-boot-sbom-tools-jarmode-cyclonedx-dependency-scanning/) | the 4.2.0-RC1 `java -Djarmode=tools -jar app.jar sbom` command (absent in 4.1.2), the CycloneDX document the build puts in the jar, `/actuator/sbom`, an SBOM that lists 8 starters not in the jar and misses the jar mode's own library, a licence rule that wrongly flags dual-licensed logback, and an OSV scan that finds Log4Shell in a deliberately old build |
| [`hexagonal/`](hexagonal) | [Hexagonal Architecture (Ports and Adapters) in Spring Boot 4](https://ankurm.com/hexagonal-architecture-ports-and-adapters-spring-boot-4/) | a 4-module Maven reactor that makes "the core has no Spring dependency" something `mvn dependency:tree` proves rather than a naming convention, testing the core with zero Spring context, and a Spring Boot 4 test-slice-artifact split (`@DataJpaTest`, `@WebMvcTest`, `TestRestTemplate`) hit and fixed along the way |
| [`actuator-in-production/`](actuator-in-production) | [Spring Boot Actuator in Production](https://ankurm.com/spring-boot-actuator-production-endpoints-security-health-indicators/) | endpoint exposure defaults, securing Actuator, custom health indicators and how they hang |
| [`spring-boot-startup-time/`](spring-boot-startup-time) | [Why Your Spring Boot App Takes 8 Seconds to Start](spring-boot-startup-time/post/post.md) | `BufferingApplicationStartup`, JFR startup events, self time vs total time, the classpath-scan tax, the JDK 25 AOT cache |
diff --git a/sbom/.gitignore b/sbom/.gitignore
new file mode 100644
index 0000000..2f7896d
--- /dev/null
+++ b/sbom/.gitignore
@@ -0,0 +1 @@
+target/
diff --git a/sbom/README.md b/sbom/README.md
new file mode 100644
index 0000000..f6b7d68
--- /dev/null
+++ b/sbom/README.md
@@ -0,0 +1,52 @@
+# sbom
+
+Companion code for [Spring Boot SBOM in Practice: the tools jar-mode sbom Command, CycloneDX and Dependency Scanning](https://ankurm.com/spring-boot-sbom-tools-jarmode-cyclonedx-dependency-scanning/).
+
+A small Spring Boot application whose build writes a CycloneDX software bill of materials into the jar, then reads it back three ways: with the new `sbom` command of the `tools` jar mode, over HTTP from Actuator, and with a vulnerability scan and a licence check.
+
+**Spring Boot 4.2.0-RC1 is a release candidate, not a GA release.** The latest GA is 4.1.2. The `sbom` jar-mode command exists only in the 4.2 line (`output/11-jarmode-commands-by-version.txt`). Everything else here works on 4.1 as well.
+
+| Part | Version |
+|---|---|
+| Spring Boot | 4.2.0-RC1 (Spring Framework 7.1.0-RC1) |
+| cyclonedx-maven-plugin | 2.9.3 (CycloneDX spec 1.6) |
+| JDK | 25 |
+
+## Run it
+
+```
+# needs JDK 25, Maven 3.9, python3 and access to api.osv.dev
+scripts/run-all.sh # rebuilds and regenerates every file in output/ (about a minute)
+java -Djarmode=tools -jar target/sbom-1.0.0.jar sbom
+java -Djarmode=tools -jar target/sbom-1.0.0.jar sbom --destination app.cdx.json
+```
+
+## What is where
+
+| File | What it does |
+|---|---|
+| `pom.xml` | Boot parent, `cyclonedx-maven-plugin` writing `META-INF/sbom/application.cdx.json`, and a `vulnerable` profile that adds log4j-core 2.14.1 (never ship it) |
+| `src/main/resources/application.properties` | exposes the `sbom` Actuator endpoint |
+| `src/test/.../ActuatorSbomTest.java` | starts the app, calls `/actuator/sbom`, writes `output/05` |
+| `src/test/.../DefaultExposureTest.java` | the endpoint with default exposure, writes `output/06` |
+| `scripts/analyse.py` | `inspect`, `compare`, `licences` and `scan` over a CycloneDX file (standard library only) |
+| `scripts/run-all.sh` | the build variants and the jar-mode runs |
+| `scripts/commands-by-version.sh` | lists the jar-mode command classes for 4.1.2 and 4.2.0-RC1 |
+
+## Captured output
+
+| Output | Content |
+|---|---|
+| `output/01-jarmode-help.txt` | the command list and `help sbom` |
+| `output/02-no-sbom.txt` | a jar built without an SBOM: no manifest attribute, the command fails with exit code 1 |
+| `output/03-sbom-command.txt` | the manifest attributes, the command, `--destination`, and a check that all three routes give the same bytes |
+| `output/04-inspect.txt` | what is inside the document |
+| `output/05-actuator-sbom.txt` | `/actuator`, `/actuator/sbom`, `/actuator/sbom/application` |
+| `output/06-actuator-sbom-not-exposed.txt` | 404 unless the endpoint is exposed |
+| `output/07-sbom-against-jar.txt` | SBOM components against the jars in `BOOT-INF/lib` |
+| `output/08-licences.txt` | licence counts and two checking rules |
+| `output/09-osv-scan-clean.txt` | the application scanned against OSV |
+| `output/10-osv-scan-vulnerable.txt` | the `vulnerable` profile scanned against OSV |
+| `output/11-jarmode-commands-by-version.txt` | `SbomCommand` present in 4.2.0-RC1, absent in 4.1.2 |
+
+Files 09 and 10 depend on the OSV database on the day they were run (the date is on the first line), so they will change over time.
diff --git a/sbom/output/01-jarmode-help.txt b/sbom/output/01-jarmode-help.txt
new file mode 100644
index 0000000..519b618
--- /dev/null
+++ b/sbom/output/01-jarmode-help.txt
@@ -0,0 +1,18 @@
+$ java -Djarmode=tools -jar sbom-1.0.0.jar help
+Usage:
+ java -Djarmode=tools -jar sbom-1.0.0.jar
+
+Available commands:
+ extract Extract the contents from the jar
+ list-layers List layers from the jar that can be extracted
+ sbom Print the SBOM from the jar
+ help Help about any command
+
+$ 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
diff --git a/sbom/output/02-no-sbom.txt b/sbom/output/02-no-sbom.txt
new file mode 100644
index 0000000..2673ab7
--- /dev/null
+++ b/sbom/output/02-no-sbom.txt
@@ -0,0 +1,7 @@
+$ unzip -p sbom-without.jar META-INF/MANIFEST.MF | grep -i sbom
+Implementation-Title: sbom
+Start-Class: com.ankurm.sbom.SbomApplication
+$ 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
diff --git a/sbom/output/03-sbom-command.txt b/sbom/output/03-sbom-command.txt
new file mode 100644
index 0000000..69a654d
--- /dev/null
+++ b/sbom/output/03-sbom-command.txt
@@ -0,0 +1,14 @@
+$ 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
+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)
diff --git a/sbom/output/04-inspect.txt b/sbom/output/04-inspect.txt
new file mode 100644
index 0000000..e6c05ae
--- /dev/null
+++ b/sbom/output/04-inspect.txt
@@ -0,0 +1,17 @@
+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
+dependency entries: 50
+direct dependencies of the application: 2
+ org.springframework.boot/spring-boot-starter-webmvc@4.2.0-RC1
+ org.springframework.boot/spring-boot-starter-actuator@4.2.0-RC1
+first three components:
+ pkg:maven/org.springframework.boot/spring-boot-starter-webmvc@4.2.0-RC1
+ pkg:maven/org.springframework.boot/spring-boot-starter@4.2.0-RC1
+ pkg:maven/org.springframework.boot/spring-boot-starter-logging@4.2.0-RC1
diff --git a/sbom/output/05-actuator-sbom.txt b/sbom/output/05-actuator-sbom.txt
new file mode 100644
index 0000000..5fa1c24
--- /dev/null
+++ b/sbom/output/05-actuator-sbom.txt
@@ -0,0 +1,6 @@
+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
diff --git a/sbom/output/06-actuator-sbom-not-exposed.txt b/sbom/output/06-actuator-sbom-not-exposed.txt
new file mode 100644
index 0000000..9155663
--- /dev/null
+++ b/sbom/output/06-actuator-sbom-not-exposed.txt
@@ -0,0 +1,2 @@
+management.endpoints.web.exposure.include=health
+GET /actuator/sbom -> 404
diff --git a/sbom/output/07-sbom-against-jar.txt b/sbom/output/07-sbom-against-jar.txt
new file mode 100644
index 0000000..2cd2206
--- /dev/null
+++ b/sbom/output/07-sbom-against-jar.txt
@@ -0,0 +1,14 @@
+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
diff --git a/sbom/output/08-licences.txt b/sbom/output/08-licences.txt
new file mode 100644
index 0000000..fc6b259
--- /dev/null
+++ b/sbom/output/08-licences.txt
@@ -0,0 +1,17 @@
+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
diff --git a/sbom/output/09-osv-scan-clean.txt b/sbom/output/09-osv-scan-clean.txt
new file mode 100644
index 0000000..b548e11
--- /dev/null
+++ b/sbom/output/09-osv-scan-clean.txt
@@ -0,0 +1,3 @@
+scanned: 2026-10-24
+queried 49 components against api.osv.dev
+components with at least one advisory: 0
diff --git a/sbom/output/10-osv-scan-vulnerable.txt b/sbom/output/10-osv-scan-vulnerable.txt
new file mode 100644
index 0000000..1724eec
--- /dev/null
+++ b/sbom/output/10-osv-scan-vulnerable.txt
@@ -0,0 +1,11 @@
+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
diff --git a/sbom/output/11-jarmode-commands-by-version.txt b/sbom/output/11-jarmode-commands-by-version.txt
new file mode 100644
index 0000000..1de31d3
--- /dev/null
+++ b/sbom/output/11-jarmode-commands-by-version.txt
@@ -0,0 +1,2 @@
+4.1.2: Command ExtractCommand ExtractLayersCommand HelpCommand ListCommand ListLayersCommand
+4.2.0-RC1: Command ExtractCommand ExtractLayersCommand HelpCommand ListCommand ListLayersCommand SbomCommand
diff --git a/sbom/pom.xml b/sbom/pom.xml
new file mode 100644
index 0000000..796b641
--- /dev/null
+++ b/sbom/pom.xml
@@ -0,0 +1,81 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.2.0-RC1
+
+
+
+ com.ankurm
+ sbom
+ 1.0.0
+ sbom
+ Spring Boot SBOMs: the 4.2 tools jar-mode command, CycloneDX, Actuator and scanning
+
+
+ 25
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ vulnerable
+
+
+ org.apache.logging.log4j
+ log4j-core
+ 2.14.1
+
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+ org.cyclonedx
+ cyclonedx-maven-plugin
+ 2.9.3
+
+
+ generate-resources
+
+ makeAggregateBom
+
+
+
+
+ json
+ application.cdx
+ ${project.build.outputDirectory}/META-INF/sbom
+ application
+
+
+
+
+
diff --git a/sbom/scripts/analyse.py b/sbom/scripts/analyse.py
new file mode 100644
index 0000000..67de17e
--- /dev/null
+++ b/sbom/scripts/analyse.py
@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+"""Reads a CycloneDX JSON file (and optionally the jar it came from). Standard library only.
+
+ analyse.py inspect SBOM
+ analyse.py compare SBOM JAR
+ analyse.py licences SBOM
+ analyse.py scan SBOM (calls https://api.osv.dev)
+"""
+import collections, json, sys, urllib.request, zipfile
+
+def load(p):
+ return json.load(open(p))
+
+def purl(c):
+ return c.get("purl") or c["bom-ref"]
+
+def inspect(p):
+ d = load(p)
+ m = d["metadata"]
+ print(f"format {d['bomFormat']} {d['specVersion']}, serial {d['serialNumber'][:13]}...")
+ print(f"lifecycle phase: {m['lifecycles'][0]['phase']}")
+ t = m["tools"]["components"][0]
+ print(f"generated by: {t['group']}:{t['name']} {t['version']}")
+ a = m["component"]
+ print(f"describes: {a['type']} {a['group']}:{a['name']}:{a['version']}")
+ c = d["components"]
+ print(f"components: {len(c)}")
+ print("by scope: " + ", ".join(f"{k} {v}" for k, v in sorted(collections.Counter(x.get('scope', '?') for x in c).items())))
+ print("with a hash: %d of %d" % (sum(1 for x in c if x.get("hashes")), len(c)))
+ print("hash algorithms on the first component: " + ", ".join(h["alg"] for h in c[0]["hashes"]))
+ print("with a licence: %d of %d" % (sum(1 for x in c if x.get("licenses")), len(c)))
+ deps = d["dependencies"]
+ print(f"dependency entries: {len(deps)}")
+ root = [x for x in deps if x["ref"] == a["bom-ref"]][0]
+ print(f"direct dependencies of the application: {len(root['dependsOn'])}")
+ for r in root["dependsOn"]:
+ print(" " + r.split("/", 1)[1].split("?")[0])
+ print("first three components:")
+ for x in c[:3]:
+ print(" " + purl(x).split("?")[0])
+
+def compare(p, jar):
+ d = load(p)
+ sb = {f"{c['name']}-{c['version']}.jar": c for c in d["components"]}
+ z = zipfile.ZipFile(jar)
+ lib = {n.rsplit("/", 1)[1] for n in z.namelist() if n.startswith("BOOT-INF/lib/") and n.endswith(".jar")}
+ print(f"components in the SBOM: {len(sb)}")
+ print(f"jars in BOOT-INF/lib: {len(lib)}")
+ print(f"in both: {len(set(sb) & lib)}")
+ print("in the SBOM but not in the jar:")
+ for n in sorted(set(sb) - lib):
+ print(" " + n)
+ print("in the jar but not in the SBOM:")
+ for n in sorted(lib - set(sb)):
+ print(" " + n)
+
+COPYLEFT = ("GPL", "LGPL", "AGPL", "MPL", "EPL", "CDDL")
+def licences(p):
+ d = load(p)
+ c = collections.Counter()
+ rows = []
+ for x in d["components"]:
+ ids = [l["license"].get("id") or l["license"].get("name") for l in x.get("licenses", [])]
+ for i in ids:
+ c[i] += 1
+ rows.append((x["name"], ids))
+ print("licence entries (a component with two licences counts once under each):")
+ for k, v in c.most_common():
+ print(f" {k:36s} {v}")
+ print()
+ naive = [n for n, ids in rows if any(any(k in i for k in ("GPL",)) for i in ids)]
+ print(f"naive rule 'fail on any GPL or LGPL entry' flags {len(naive)}: {', '.join(naive)}")
+ strict = [n for n, ids in rows if ids and all(any(k in i for k in ("GPL",)) for i in ids)]
+ print(f"rule 'fail only when every listed licence is GPL or LGPL' flags {len(strict)}")
+ print("no licence listed: " + (", ".join(n for n, ids in rows if not ids) or "none"))
+ print("dual-licensed components (more than one entry):")
+ for n, ids in rows:
+ if len(ids) > 1:
+ print(f" {n}: {' / '.join(ids)}")
+
+def post(url, body):
+ r = urllib.request.Request(url, json.dumps(body).encode(), {"Content-Type": "application/json"})
+ return json.load(urllib.request.urlopen(r, timeout=30))
+
+def get(url):
+ return json.load(urllib.request.urlopen(url, timeout=30))
+
+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]}")
+
+if __name__ == "__main__":
+ {"inspect": lambda: inspect(sys.argv[2]), "compare": lambda: compare(sys.argv[2], sys.argv[3]),
+ "licences": lambda: licences(sys.argv[2]), "scan": lambda: scan(sys.argv[2])}[sys.argv[1]]()
diff --git a/sbom/scripts/commands-by-version.sh b/sbom/scripts/commands-by-version.sh
new file mode 100755
index 0000000..a699085
--- /dev/null
+++ b/sbom/scripts/commands-by-version.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+# Lists the command classes inside spring-boot-jarmode-tools for 4.1.2 and 4.2.0-RC1, straight from Maven Central.
+set -uo pipefail
+cd "$(dirname "$0")/.."
+for v in 4.1.2 4.2.0-RC1; do
+ f=/tmp/jarmode-tools-$v.jar
+ for try in 1 2 3 4 5; do
+ curl -s -o "$f" "https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-jarmode-tools/$v/spring-boot-jarmode-tools-$v.jar"
+ unzip -tq "$f" >/dev/null 2>&1 && break
+ sleep 5
+ done
+ echo "$v: $(unzip -Z1 "$f" | grep 'Command.class' | grep -v '\$' | sed 's#.*/##; s#.class##' | sort | tr '\n' ' ')"
+done
diff --git a/sbom/scripts/run-all.sh b/sbom/scripts/run-all.sh
new file mode 100755
index 0000000..61c625b
--- /dev/null
+++ b/sbom/scripts/run-all.sh
@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+# Regenerates every file in output/. Needs JDK 25, Maven 3.9, python3 and network access to api.osv.dev.
+# Spring Boot 4.2.0-RC1 is a release candidate, so the first run downloads it from Maven Central.
+set -uo pipefail
+cd "$(dirname "$0")/.."
+rm -rf output && mkdir output
+JAR=target/sbom-1.0.0.jar
+TOOLS="java -Djarmode=tools -jar $JAR"
+
+# Tests write 05 and 06 (and need the SBOM the build puts into target/classes).
+mvn -B -q clean test >/dev/null 2>&1 || { echo "tests failed"; exit 1; }
+
+# A jar built with SBOM generation switched off.
+mvn -B -q clean package -DskipTests -Dcyclonedx.skip=true >/dev/null 2>&1
+cp $JAR /tmp/sbom-without.jar
+{
+ echo '$ unzip -p sbom-without.jar META-INF/MANIFEST.MF | grep -i sbom'
+ unzip -p /tmp/sbom-without.jar META-INF/MANIFEST.MF | grep -i sbom || echo '(no matching line)'
+ echo '$ java -Djarmode=tools -jar sbom-without.jar sbom'
+ java -Djarmode=tools -jar /tmp/sbom-without.jar sbom 2>&1
+ echo "exit code: $?"
+} > output/02-no-sbom.txt
+
+# The normal build.
+mvn -B -q clean package -DskipTests >/dev/null 2>&1
+{
+ echo '$ java -Djarmode=tools -jar sbom-1.0.0.jar help'
+ $TOOLS help 2>&1
+ echo
+ echo '$ java -Djarmode=tools -jar sbom-1.0.0.jar help sbom'
+ $TOOLS help sbom 2>&1
+} > output/01-jarmode-help.txt
+
+{
+ echo '$ unzip -p sbom-1.0.0.jar META-INF/MANIFEST.MF | grep -i sbom'
+ unzip -p $JAR META-INF/MANIFEST.MF | grep -i sbom
+ $TOOLS sbom > /tmp/from-command.json; echo "exit code of 'sbom': $?"
+ echo '$ java -Djarmode=tools -jar sbom-1.0.0.jar sbom (first 120 characters)'
+ head -c 120 /tmp/from-command.json; echo
+ $TOOLS sbom --destination /tmp/from-destination.json >/dev/null 2>&1; echo "--destination exit code: $?"
+ unzip -p $JAR META-INF/sbom/application.cdx.json > /tmp/from-zip.json
+ echo "bytes: command $(wc -c < /tmp/from-command.json), --destination $(wc -c < /tmp/from-destination.json), unzip $(wc -c < /tmp/from-zip.json)"
+ echo "SHA-256 of the three files: $(sha256sum /tmp/from-command.json /tmp/from-destination.json /tmp/from-zip.json | cut -d' ' -f1 | sort -u | wc -l) distinct value(s)"
+} > output/03-sbom-command.txt
+
+bash scripts/commands-by-version.sh > output/11-jarmode-commands-by-version.txt
+python3 scripts/analyse.py inspect /tmp/from-command.json > output/04-inspect.txt
+python3 scripts/analyse.py compare /tmp/from-command.json $JAR > output/07-sbom-against-jar.txt
+python3 scripts/analyse.py licences /tmp/from-command.json > output/08-licences.txt
+{ echo "scanned: $(date -u +%Y-%m-%d)"; python3 scripts/analyse.py scan /tmp/from-command.json; } > output/09-osv-scan-clean.txt
+
+# The same build with one old library added.
+mvn -B -q clean package -DskipTests -Pvulnerable >/dev/null 2>&1
+java -Djarmode=tools -jar $JAR sbom > /tmp/vulnerable.json
+{ echo "scanned: $(date -u +%Y-%m-%d)"; python3 scripts/analyse.py scan /tmp/vulnerable.json; } > output/10-osv-scan-vulnerable.txt
+
+mvn -B -q clean package -DskipTests >/dev/null 2>&1
+echo "done: $(ls output | wc -l) files in output/"
diff --git a/sbom/src/main/java/com/ankurm/sbom/SbomApplication.java b/sbom/src/main/java/com/ankurm/sbom/SbomApplication.java
new file mode 100644
index 0000000..8a55edc
--- /dev/null
+++ b/sbom/src/main/java/com/ankurm/sbom/SbomApplication.java
@@ -0,0 +1,11 @@
+package com.ankurm.sbom;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class SbomApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(SbomApplication.class, args);
+ }
+}
diff --git a/sbom/src/main/resources/application.properties b/sbom/src/main/resources/application.properties
new file mode 100644
index 0000000..e968435
--- /dev/null
+++ b/sbom/src/main/resources/application.properties
@@ -0,0 +1,2 @@
+# Only health is exposed over HTTP by default. The sbom endpoint has to be listed explicitly.
+management.endpoints.web.exposure.include=health,sbom
diff --git a/sbom/src/test/java/com/ankurm/sbom/ActuatorSbomTest.java b/sbom/src/test/java/com/ankurm/sbom/ActuatorSbomTest.java
new file mode 100644
index 0000000..e6533b1
--- /dev/null
+++ b/sbom/src/test/java/com/ankurm/sbom/ActuatorSbomTest.java
@@ -0,0 +1,70 @@
+package com.ankurm.sbom;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.ankurm.sbom.support.Transcript;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.ObjectMapper;
+
+/**
+ * Starts the application and asks Actuator for its SBOM. The file is the one the CycloneDX plugin wrote into
+ * target/classes/META-INF/sbom during generate-resources, so the test needs no jar.
+ */
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class ActuatorSbomTest {
+
+ @LocalServerPort
+ int port;
+
+ private final HttpClient http = HttpClient.newHttpClient();
+ private final ObjectMapper json = new ObjectMapper();
+
+ private HttpResponse get(String path) throws Exception {
+ return http.send(HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)).build(),
+ HttpResponse.BodyHandlers.ofString());
+ }
+
+ @Test
+ void actuatorServesTheSbomTheBuildProduced() throws Exception {
+ Transcript t = new Transcript("05-actuator-sbom");
+
+ JsonNode index = json.readTree(get("/actuator").body());
+ t.line("GET /actuator -> links: %s", names(index.get("_links")));
+
+ HttpResponse ids = get("/actuator/sbom");
+ t.line("GET /actuator/sbom -> %d %s", ids.statusCode(), ids.body());
+
+ HttpResponse app = get("/actuator/sbom/application");
+ JsonNode served = json.readTree(app.body());
+ t.line("GET /actuator/sbom/application -> %d, content-type %s", app.statusCode(),
+ app.headers().firstValue("content-type").orElse("?"));
+ t.line(" bomFormat %s, specVersion %s, components %d", served.get("bomFormat").asString(),
+ served.get("specVersion").asString(), served.get("components").size());
+
+ JsonNode onDisk = json.readTree(Files.readString(Path.of("target/classes/META-INF/sbom/application.cdx.json")));
+ t.line("same document as the file the build wrote: %s", served.equals(onDisk));
+
+ HttpResponse unknown = get("/actuator/sbom/nope");
+ t.line("GET /actuator/sbom/nope -> %d", unknown.statusCode());
+ t.write();
+
+ assertThat(ids.body()).isEqualTo("{\"ids\":[\"application\"]}");
+ assertThat(served).isEqualTo(onDisk);
+ assertThat(unknown.statusCode()).isEqualTo(404);
+ }
+
+ private static String names(JsonNode links) {
+ StringBuilder sb = new StringBuilder();
+ links.propertyNames().forEach(n -> sb.append(sb.isEmpty() ? "" : ", ").append(n));
+ return sb.toString();
+ }
+}
diff --git a/sbom/src/test/java/com/ankurm/sbom/DefaultExposureTest.java b/sbom/src/test/java/com/ankurm/sbom/DefaultExposureTest.java
new file mode 100644
index 0000000..8588c04
--- /dev/null
+++ b/sbom/src/test/java/com/ankurm/sbom/DefaultExposureTest.java
@@ -0,0 +1,34 @@
+package com.ankurm.sbom;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.ankurm.sbom.support.Transcript;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+
+/** Removes our one-line exposure setting to show what a stock application does. */
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
+ properties = "management.endpoints.web.exposure.include=health")
+class DefaultExposureTest {
+
+ @LocalServerPort
+ int port;
+
+ @Test
+ void sbomIsNotExposedUnlessYouAskForIt() throws Exception {
+ HttpClient http = HttpClient.newHttpClient();
+ HttpResponse r = http.send(
+ HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/actuator/sbom")).build(),
+ HttpResponse.BodyHandlers.ofString());
+ Transcript t = new Transcript("06-actuator-sbom-not-exposed");
+ t.line("management.endpoints.web.exposure.include=health");
+ t.line("GET /actuator/sbom -> %d", r.statusCode());
+ t.write();
+ assertThat(r.statusCode()).isEqualTo(404);
+ }
+}
diff --git a/sbom/src/test/java/com/ankurm/sbom/support/Transcript.java b/sbom/src/test/java/com/ankurm/sbom/support/Transcript.java
new file mode 100644
index 0000000..2fb277e
--- /dev/null
+++ b/sbom/src/test/java/com/ankurm/sbom/support/Transcript.java
@@ -0,0 +1,33 @@
+package com.ankurm.sbom.support;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Collects lines and writes them to output/NN-name.txt, so the test and the transcript cannot drift apart. */
+public class Transcript {
+
+ private final String name;
+ private final List lines = new ArrayList<>();
+
+ public Transcript(String name) {
+ this.name = name;
+ }
+
+ public void line(String format, Object... args) {
+ lines.add(args.length == 0 ? format : String.format(format, args));
+ }
+
+ public void write() {
+ try {
+ Path dir = Path.of("output");
+ Files.createDirectories(dir);
+ Files.write(dir.resolve(name + ".txt"), lines);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+}