DEV Community

Aiden Bolin
Aiden Bolin

Posted on • Originally published at mindsparkstack.com

Matching live CVEs to your actual apt packages in ~800 lines of Python

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}",
            }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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, polls https://ubuntu.com/security/notices.json
  • stackpatch-dsa-poller.py — every 30 minutes, parses https://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)