Add sbom module: Spring Boot 4.2.0-RC1 tools jar-mode sbom command, CycloneDX in the jar, Actuator endpoint, licence check and OSV scan
Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cfo2K4u4rF18QsPXPFnytf
This commit is contained in:
@@ -9,6 +9,7 @@ files.
|
|||||||
|
|
||||||
| Directory | Article | What it demonstrates |
|
| 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 |
|
| [`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 |
|
| [`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 |
|
| [`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 |
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
target/
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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/[email protected]
|
||||||
|
org.springframework.boot/[email protected]
|
||||||
|
first three components:
|
||||||
|
pkg:maven/org.springframework.boot/[email protected]
|
||||||
|
pkg:maven/org.springframework.boot/[email protected]
|
||||||
|
pkg:maven/org.springframework.boot/[email protected]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
management.endpoints.web.exposure.include=health
|
||||||
|
GET /actuator/sbom -> 404
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
scanned: 2026-10-24
|
||||||
|
queried 49 components against api.osv.dev
|
||||||
|
components with at least one advisory: 0
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
4.1.2: Command ExtractCommand ExtractLayersCommand HelpCommand ListCommand ListLayersCommand
|
||||||
|
4.2.0-RC1: Command ExtractCommand ExtractLayersCommand HelpCommand ListCommand ListLayersCommand SbomCommand
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>4.2.0-RC1</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.ankurm</groupId>
|
||||||
|
<artifactId>sbom</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>sbom</name>
|
||||||
|
<description>Spring Boot SBOMs: the 4.2 tools jar-mode command, CycloneDX, Actuator and scanning</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>25</java.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<profiles>
|
||||||
|
<!-- Adds a library with a famous vulnerability so the scanner has something to find. Never ship this. -->
|
||||||
|
<profile>
|
||||||
|
<id>vulnerable</id>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.logging.log4j</groupId>
|
||||||
|
<artifactId>log4j-core</artifactId>
|
||||||
|
<version>2.14.1</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</profile>
|
||||||
|
</profiles>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.cyclonedx</groupId>
|
||||||
|
<artifactId>cyclonedx-maven-plugin</artifactId>
|
||||||
|
<version>2.9.3</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<phase>generate-resources</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>makeAggregateBom</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
<configuration>
|
||||||
|
<outputFormat>json</outputFormat>
|
||||||
|
<outputName>application.cdx</outputName>
|
||||||
|
<outputDirectory>${project.build.outputDirectory}/META-INF/sbom</outputDirectory>
|
||||||
|
<projectType>application</projectType>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -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]]()
|
||||||
Executable
+13
@@ -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
|
||||||
Executable
+58
@@ -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/"
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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<String> 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<String> ids = get("/actuator/sbom");
|
||||||
|
t.line("GET /actuator/sbom -> %d %s", ids.statusCode(), ids.body());
|
||||||
|
|
||||||
|
HttpResponse<String> 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<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user