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:
@@ -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/"
|
||||
Reference in New Issue
Block a user