Matching live CVEs to your actual apt packages in ~800 lines of Python
41,000+ CVEs are indexed in the NVD right now. The vast majority do not affect you. The interesting engineering question is: which ones do, and what is the exact one-liner to fix them?
I spent a weekend building the matcher. Here is the shape of the problem and the shape of the solution.
The matching problem
A CVE record from one of the upstream feeds (Ubuntu USN, Debian Security Tracker, Alpine secdb, OSV.dev for the RHEL family, NVD as a fallback) looks roughly like:
{
"id": "USN-7100-1",
"summary": "OpenSSH client vulnerability",
"cves": ["CVE-2026-35414", "CVE-2026-35387"],
"releases": {
"noble": {
"binaries": {
"openssh-client": "1:9.6p1-3ubuntu13.5"
}
}
},
"published": "2026-05-09T00:00:00Z"
}
Your server, meanwhile, has a snapshot like:
$ dpkg-query -W -f='${Package}=${Version}\n' | head -3
openssh-client=1:9.6p1-3ubuntu13.4
openssh-server=1:9.6p1-3ubuntu13.4
openssh-sftp-server=1:9.6p1-3ubuntu13.4
Match logic: for each advisory, intersect on package name. For every hit, compare the installed version against the fixed version using dpkg --compare-versions. If installed lt fixed, you are affected; emit the exact apt install <pkg>=<fixed> one-liner.
The actual matcher loop
The core is brutally simple:
import subprocess
from pathlib import Path
def is_affected(installed: str, fixed: str) -> bool:
"""Wrap dpkg --compare-versions. Returns True if installed < fixed."""
rc = subprocess.run(
["dpkg", "--compare-versions", installed, "lt", fixed],
check=False,
).returncode
return rc == 0
def match_advisory(advisory: dict, inventory: dict[str, str], codename: str):
fixes = advisory.get("releases", {}).get(codename, {}).get("binaries", {})
for pkg, fixed_version in fixes.items():
installed = inventory.get(pkg)
if installed and is_affected(installed, fixed_version):
yield {
"advisory": advisory["id"],
"cves": advisory["cves"],
"package": pkg,
"installed": installed,
"fixed": fixed_version,
"remediation": f"sudo apt update && sudo apt install --only-upgrade {pkg}={fixed_version}",
}
Run that over the full advisory cache (USN + DSA + Alpine secdb + OSV-RPM) against dpkg-query output, and you get a per-server findings list in seconds.
Where it gets interesting
The version compare is the easy part. The hard parts are the edge cases.
1. Kernel-module CVEs. Some CVEs aren't a package version bump at all. CVE-2026-31431 (the "Copy Fail" algif_aead local-priv-esc, disclosed late April) was patched at the upstream kernel level — but Ubuntu had not shipped a fixed kernel for noble at advisory time. So apt install --only-upgrade linux-image-generic was a no-op. The correct mitigation was a modprobe blacklist:
# /etc/modprobe.d/cve-2026-31431-copyfail.conf
blacklist algif_aead
install algif_aead /bin/false
So the matcher needs a playbook_class field on each advisory: apt_upgrade, kernel_reboot, modprobe_block, apt_upgrade_esm (Ubuntu Pro), oss_only_fix. The default is apt_upgrade; the rest are hand-curated for kernel/firmware CVEs because the upstream feeds don't tag them.
2. Ubuntu Pro / ESM. Many "fixed" Ubuntu CVEs are fixed only in Ubuntu Pro (the ESM channel). Free for personal + small-team use, but the fix won't apply without attaching a Pro token. The matcher emits a different playbook for those:
sudo pro attach <token>
sudo apt update && sudo apt install --only-upgrade <pkg>
3. Multi-arch and source vs binary package confusion. libssl3 (binary) and openssl (source) are both real package names. The advisory binaries map is the source of truth — match on that, not the source name.
4. Apt pinning and snapshot repos. If a customer is pinned to an older noble-updates snapshot, the fixed version may not be installable on their box. The matcher flags this as a remediation_blocked: pin_conflict state instead of pretending the fix is one apt command away.
The feed plumbing
The actual fetcher is 9 cron jobs:
-
stackpatch-usn-poller.py— every 30 minutes, pollshttps://ubuntu.com/security/notices.json -
stackpatch-dsa-poller.py— every 30 minutes, parseshttps://security-tracker.debian.org/tracker/data/json -
stackpatch-alpine-secdb-poller.py— every hour, fetches alpine-secdb branch refs -
stackpatch-osv-rpm-poller.py— every hour, OSV.dev RHEL/AlmaLinux/Rocky ecosystems -
stackpatch-nvd-poller.py— every 6 hours, NVD modified feed as a backstop -
stackpatch-matcher.py— every hour, runs the match loop against the inventory cache -
stackpatch-index-builder.py— twice daily, regenerates the per-CVE static pages -
stackpatch-alert-dispatcher.py— every 15 minutes, fans out new findings to email/Telegram/Discord webhooks -
stackpatch-customer-backup.py— daily, snapshots inventory + findings per server
State lives as JSONL at /var/lib/stackpatch/{advisories,findings,inventory}/. Not a database — for fewer than 10,000 servers, JSONL + atomic rename is faster and easier to back up than Postgres. A V2 with FTS will probably move it, but premature SQL is a tax.
The "use it on yourself" rule
The first server we pointed the matcher at was our own production VPS — the one this blog is served from. The matcher found 4 outstanding CVEs we hadn't seen. We patched 3 (the OpenSSH set) within an hour; the 4th is gated on attaching a Pro token, which is correctly flagged. That ratio — "matcher finds real CVEs on its own author's box" — is the only credibility test I trust for a security tool.
If you run Linux on a VPS and you don't have a continuous CVE-to-action pipeline, the free quickscan takes about 30 seconds. Or read the open methodology page. I'm @aiden_bolin in the comments if you have edge-case advisories the matcher misses — that is exactly the feedback I'm hunting for right now.
Top comments (0)