← All write-ups

Ranking vulnerabilities by exploitation, not severity

Details are generalized: no hostnames, addresses, identifiers or credentials.

How this was built: I design, direct and review the work, and build it hands-on with Claude, an AI coding assistant. The decisions, and the homelab, are mine.

A nightly scan of every container in my homelab returned more than 4,000 vulnerability findings on its first run. Nobody works a list that long; it gets ignored. This is how it became about seventeen concrete actions, and why the findings that mattered most were exactly the ones a severity filter threw away.

The problem

My homelab runs roughly 80 containers and Linux guests across a NAS and a Proxmox hypervisor. A nightly Trivy scan of every running container image, limited to HIGH and CRITICAL findings that already have a fix, reported over 4,000 findings across 52 of 60 images on day one.

Sorting by CVSS severity barely helps. A CVSS score describes how bad a flaw could be. It says nothing about whether anyone is using it. The first question for any triage queue is simpler: which of these are being exploited in the wild right now?

What I built

  1. Scan. Trivy runs over every running container image, and as a binary on the hypervisor and its guests, reporting only HIGH/CRITICAL findings with a fix available.
  2. Diff. Each finding is keyed as image | CVE | package and compared with the previous night's baseline, so only new findings raise an alert. The first run seeds the baseline silently.
  3. Enrich. Every CVE is looked up in CISA's Known Exploited Vulnerabilities (KEV) catalog.
  4. Rank. Findings are tiered Actively exploited → Critical → High. An exploited "High" outranks every unexploited "Critical".
  5. Filter. Components that the platform's own auto-updater will replace are dropped, because they fix themselves. On the NAS that left 32 of 72 components needing a person. The filter fails safe: if the lookup can't be completed, nothing is dropped.
  6. Collapse. CVEs are grouped by the component that fixes them. One image update often clears dozens of CVEs, so the unit of work is the component, not the CVE.
  7. Report and act. A summary goes to a private Discord channel, and the actively-exploited tier wakes a remediation agent.
StageResult
Raw HIGH/CRITICAL findings with a fix~2,400
NAS components the auto-updater does not cover32 of 72
Component-level actions for a person~17

What went wrong along the way

A severity cap removed exactly the top tier

To keep alerts short, an early version that the assistant and I built trimmed the list by severity before the KEV lookup. On live data, every actively exploited finding was rated HIGH, not CRITICAL. The cap discarded precisely the findings that mattered most, while a code comment the assistant had written confidently said it couldn't. I caught it by checking the claim against real data.

The rule now: a stage that can't see the priority signal doesn't get to rank or cut. It passes every finding forward in compact form, and only the stage that knows about exploitation decides what to drop.

Parallel scans silently undercounted

Running several Trivy containers at once against one shared vulnerability database, each updating it, produced 1,839, 716 and 297 findings on three identical runs. Nothing errored. Undercounting is the dangerous direction, because a smaller number looks like good news. The fix was to download the database once up front and then run the scans in parallel with updates disabled.

The argument that was too long

The findings list was passed to the next step as a single SSH argument, and it broke once the list grew. Linux caps a single argument at 128 KB (MAX_ARG_STRLEN), far below the 2 MB total that ARG_MAX suggests. Data now travels on stdin or as a file.

Advisories with no CVE at all

A media-server vendor once published a security advisory with no CVE and no technical detail. Trivy can't see that kind of issue, because it reads OS and library packages, not a vendor's own application. A CVE watchlist can't hold a CVE that doesn't exist.

A companion Advisory Watch now reads vendor security feeds, GitHub Security Advisories and the KEV catalog every six hours. It matches them against an inventory derived live from what is actually running, so a newly deployed container is covered on the next run with no list to maintain. Three matching details mattered:

Takeaways