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); + } + } +}