Vulnerability scanning — running it, and reading the result#
Once an SBOM exists (sbom.md), finding known vulnerabilities is one
command. Interpreting the output is the hard part, and the raw count is
close to meaningless on both of this project’s images. This page covers both.
Run against the SBOM, not the image: seconds instead of a multi-gigabyte pull, which is what makes it viable on a schedule against every published tag.
grype sbom:out/sbom/scanned-linux-amd64.spdx.json -o json --file out/sbom/vulns-linux-amd64.json
grype sbom:out/sbom/scanned-windows-amd64.spdx.json -o json --file out/sbom/vulns-windows-amd64.json
Read this before you read the numbers#
Two things make the headline count untrustworthy on these images specifically. Neither is a grype defect; both are properties of what is being scanned.
1. The Linux distro cannot be identified#
grype reported distro: {"name": "ubuntu", "version": ""} — an empty
version. Ubuntu 26.04 is not in its database, so for every deb package it
had no way to know what 26.04 already patched and matched broadly instead.
9,407 of 9,546 Linux matches (98.5 %) are deb. Treat them as unverified,
not as findings. The trustworthy remainder is the ~139 matches from
manifest-based ecosystems (python, java-archive, npm, go-module), where the
version is read from a real package manifest.
2. The Windows scan is mostly CPE guesswork#
There is no package database in a Windows base image at all. Of 1,472 matches,
1,273 carry no ecosystem — they are CPE guesses against version resources
scraped out of DLLs. The 199 matches from dotnet, python and go-module
are the ones with a real package identity.
The filter that gives you the signal#
# Critical/High from manifest-based ecosystems only
python3 - out/sbom/vulns-linux-amd64.json <<'PY'
import json, sys, collections
RELIABLE = {"python", "java-archive", "npm", "go-module", "dotnet", "rust-crate"}
m = json.load(open(sys.argv[1], encoding="utf-8"))["matches"]
hits = [x for x in m
if x["artifact"].get("type") in RELIABLE
and x["vulnerability"].get("severity") in ("Critical", "High")]
agg = collections.Counter((x["artifact"]["name"], x["artifact"].get("version", ""))
for x in hits)
print(f"{len(hits)} Critical/High from {len(agg)} packages")
for (n, v), c in agg.most_common():
print(f" {c:3d}x {n} {v}")
PY
Then trace anything real back to where it actually lives — sourceInfo in the
syft SBOM gives the path, and the path usually explains the finding:
python3 - out/sbom/scanned-linux-amd64.spdx.json mlflow <<'PY'
import json, sys
doc = json.load(open(sys.argv[1], encoding="utf-8"))
for p in doc["packages"]:
if p["name"].lower() == sys.argv[2].lower():
print(p.get("versionInfo"), "->", p.get("sourceInfo", "(no path)"))
PY
Baseline measured 2026-08-25#
Recorded so the next run can be compared against something rather than read cold. grype 0.117.0, against the published images.
Linux |
Windows |
|
|---|---|---|
Total matches |
9,546 |
1,472 |
Critical / High |
7 / 172 |
43 / 775 |
Reliable-ecosystem matches |
~139 |
199 |
…of which Critical/High |
~67 |
92 |
Dominant noise source |
|
CPE guesses (1,273) |
The pattern both images share: almost none of the real exposure comes from a
dependency this project chose. It comes from what vendors bundle inside their
SDKs, where the pins in versions.env have no reach.
Finding |
Where it actually lives |
|---|---|
Python 3.11.4, ~18 Critical/High |
inside the Android NDK — not the pinned 3.14 |
|
inside the Android cmdline-tools jars |
Git 2.15.1 (2017), CVE-2025-48384 |
inside Flutter’s bundled |
go |
inside |
|
inside VS Build Tools’ TestWindow RemoteAgent |
|
the |
|
|
Two observations worth acting on rather than filing:
mlflowis the actionable one. It is ours, it is Critical, and it has a fix. OrchestrANT’suv.lockat the pinnedAPP_REFstill has 3.14.0 (2026-09-25).The
uvcache is build scratch that shipped.gitpython,sqlparseandmlflowon the Windows image are downloaded wheels sitting underscoop/persist/uv/cache/, not installed packages. Scrubbing that cache would remove real bytes and several findings at once — see the scratch-scrubbing rule inwindows-build-invariants.md.
Do NOT gate on --fail-on high here#
The obvious next step is the wrong one. On these images a naive threshold gate:
grype sbom:... --fail-on high # DON'T -- fails permanently, teaches nothing
fails on 172 Linux and 775 Windows findings, the overwhelming majority of which are unverifiable distro or CPE matches. A gate that can never go green gets disabled within a week, and then nothing is watching at all.
Gate on something that can be green:
restrict to manifest-based ecosystems (the filter above);
start at Critical only, and only where a fix exists (
fix.state == "fixed");exclude findings whose
sourceInfopath is inside a vendor SDK you do not control — record them instead, so a change in that list is visible;revisit the
debhalf once grype’s database knows Ubuntu 26.04.
A finding is a prompt, not a verdict#
Presence of a vulnerable version does not mean it is reachable, let alone exploitable. The vocabulary for recording that judgement is VEX — “we ship this, it is not exploitable here, and this is why”. Without it, every scan re-raises the same questions and the answers live in someone’s memory.
What each outcome means in practice:
Outcome |
What to do |
|---|---|
Ours, fix exists |
Bump it. |
Bundled by a vendor SDK |
Not fixable by a pin. Record it, watch for an SDK update. |
Build scratch that shipped |
Stop shipping it — better than tracking it. |
Unverifiable (unknown distro / CPE guess) |
Do not act, do not gate. Re-check when the database improves. |