<!--
Copyright (c) 2025 Kataglyphis
SPDX-License-Identifier: MIT
-->

# Vulnerability scanning — running it, and reading the result

Once an SBOM exists ([`sbom.md`](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.

```bash
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

```bash
# 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:

```bash
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 `:latest-cross` (today's `:latest`) | Windows `:winamd64` |
|---|---|---|
| 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 | `deb`, unknown distro (9,407) | 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 |
| `protobuf-java` **2.6.1** (2015), `bcprov-jdk18on` 1.79 | inside the **Android cmdline-tools** jars |
| Git **2.15.1** (2017), CVE-2025-48384 | inside **Flutter's** bundled `mingit` — `GIT_VERSION=2.55.0` does not govern it |
| go `stdlib` go1.25.1, 24 CVEs | inside `git-lfs.exe` |
| `MessagePack` 2.5.198, 12 CVEs | inside **VS Build Tools**' TestWindow RemoteAgent |
| `gitpython`, `sqlparse`, `mlflow` | the **`uv` download cache**, not an installed environment |
| **`mlflow` 3.14.0** (Critical, fix available) | `/opt/venv` — **the one that is genuinely ours** |

Two observations worth acting on rather than filing:

- **`mlflow` is the actionable one.** It is ours, it is Critical, and it has a fix.
  OrchestrANT's `uv.lock` at the pinned `APP_REF` still has 3.14.0 (2026-09-25).
- **The `uv` cache is build scratch that shipped.** `gitpython`, `sqlparse` and
  `mlflow` on the Windows image are downloaded wheels sitting under
  `scoop/persist/uv/cache/`, not installed packages. Scrubbing that cache would
  remove real bytes and several findings at once — see the scratch-scrubbing
  rule in [`windows-build-invariants.md`](windows-build-invariants.md#scratch-must-be-scrubbed-inside-the-layer-that-created-it).

## Do NOT gate on `--fail-on high` here

The obvious next step is the wrong one. On these images a naive threshold gate:

```bash
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 `sourceInfo` path is inside a vendor SDK you do not
  control — record them instead, so a change in that list is visible;
- revisit the `deb` half 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. `mlflow` is this case. |
| 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. |
