Report Unpatched CVEs on Debian Servers With OSV.dev

– min read

Every time one of the kernel CVEs I write about lands here, the same question shows up in my inbox: which Debian boxes are still unpatched? I used to answer it by scrolling the security tracker by hand until I put a 106-line Python script on top of the OSV.dev API instead.

Sources: OSV.dev | github.com/google/osv.dev


What the script does

It takes a plain name version list, sends it to the OSV batch endpoint, and prints one row per advisory: installed version, advisory ID, the version that fixes it, and how many CVEs that advisory covers. No API key, no dependencies, only stdlib.


Requirements

  1. Any Debian-based host (the version detection reads the +debNN suffix)
  2. Python 3.8+ on a machine that can reach api.osv.dev
  3. Optional: dpkg-query to generate the package list


Step 1: Dump the package list

On the box you are auditing, one command gives the script everything it needs:

dpkg-query -W -f='${Package} ${Version}\n' > /tmp/packages.txt
cat /tmp/packages.txt

My test file below is a synthetic five-package list (old versions of real Debian 11 packages), but every advisory and fix version in this post came back live from the OSV API while I was writing it.


Step 2: The script

Save it as osv-patch-report.py. This is the exact file I ran:

#!/usr/bin/env python3
"""osv-patch-report.py - turn a dpkg package list into a CVE punch list.

Reads lines of "<package> <version>" (e.g. from
`dpkg-query -W -f='${Package} ${Version}\n'`), asks the OSV.dev batch API
what is unpatched, and prints one row per advisory with the fixed version
and CVE aliases. Exit code 1 with --check if anything is vulnerable.
"""
import argparse
import json
import re
import sys
import urllib.request

OSV_BATCH = "https://api.osv.dev/v1/querybatch"
OSV_VULN = "https://api.osv.dev/v1/vulns/{}"
UA = "osv-patch-report/1.0"
BATCH = 100  # packages per querybatch call


def http_json(url, payload=None):
    data = json.dumps(payload).encode() if payload else None
    req = urllib.request.Request(url, data=data)
    req.add_header("User-Agent", UA)
    if data:
        req.add_header("Content-Type", "application/json")
    with urllib.request.urlopen(req, timeout=30) as resp:
        return json.load(resp)


def read_packages(path):
    pkgs = []
    for line in open(path):
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        name, _, version = line.partition(" ")
        if not version:
            sys.exit(f"bad line (need 'name version'): {line}")
        pkgs.append((name, version))
    return pkgs


def debian_series(version):
    m = re.search(r"deb(\d+)", version)
    return f"Debian:{m.group(1)}" if m else "Debian:12"


def fixed_in(vuln, ecosystem):
    """Fix version for OUR release, picked from the matching affected entry."""
    fallback = "?"
    for aff in vuln.get("affected", []):
        for rng in aff.get("ranges", []):
            fixed = [e["fixed"] for e in rng.get("events", []) if "fixed" in e]
            if not fixed:
                continue
            if aff.get("package", {}).get("ecosystem") == ecosystem:
                return fixed[-1]
            fallback = fixed[-1]
    return fallback


def cves(vuln):
    ids = [a for a in vuln.get("aliases", []) if a.startswith("CVE-")]
    ids += [u for u in vuln.get("upstream", []) if u.startswith("CVE-")]
    return sorted(set(ids))


def main():
    ap = argparse.ArgumentParser(description="OSV patch report for dpkg lists")
    ap.add_argument("pkglist", help="file of '<name> <version>' lines, - for stdin")
    ap.add_argument("--check", action="store_true", help="exit 1 if anything is unpatched")
    ap.add_argument("--full-cve", action="store_true", help="list every CVE, not just the count")
    args = ap.parse_args()

    src = sys.stdin if args.pkglist == "-" else open(args.pkglist)
    pkgs = read_packages(src) if src is sys.stdin else read_packages(args.pkglist)

    rows = []
    for i in range(0, len(pkgs), BATCH):
        chunk = pkgs[i:i + BATCH]
        queries = [{"package": {"name": n, "ecosystem": debian_series(v)},
                    "version": v} for n, v in chunk]
        ecosystems = [debian_series(v) for _, v in chunk]
        res = http_json(OSV_BATCH, {"version": 1, "queries": queries})
        for (name, version), eco, hit in zip(chunk, ecosystems, res.get("results", [])):
            for ref in hit.get("vulns", []):
                det = http_json(OSV_VULN.format(ref["id"]))
                rows.append((name, version, ref["id"],
                             fixed_in(det, eco), cves(det)))

    rows.sort(key=lambda r: (-len(r[4]), r[0]))
    print(f"{'PACKAGE':<16}{'INSTALLED':<22}{'ADVISORY':<12}{'FIXED':<22}{'CVEs'}")
    print("-" * 86)
    for name, version, adv, fixed, cv in rows:
        cell = ", ".join(cv) if args.full_cve else str(len(cv))
        print(f"{name:<16}{version:<22}{adv:<12}{fixed:<22}{cell}")
    uniq = sorted({c for r in rows for c in r[4]})
    print("-" * 86)
    print(f"{len(rows)} advisories, {len(uniq)} unique CVEs across {len(pkgs)} packages")
    if args.check and rows:
        sys.exit(1)


if __name__ == "__main__":
    main()


Step 3: Run the report

python3 osv-patch-report.py packages.txt

Real output, five packages in about five seconds:

PACKAGE         INSTALLED             ADVISORY    FIXED                 CVEs
--------------------------------------------------------------------------------------
openssl         1.1.1n-0+deb11u1      DLA-4490-1  1.1.1w-0+deb11u5      7
openssl         1.1.1n-0+deb11u1      DLA-3942-1  1.1.1n-0+deb11u6      6
openssl         1.1.1n-0+deb11u1      DLA-3942-2  1.1.1w-0+deb11u2      6
openssl         1.1.1n-0+deb11u1      DSA-5343-1  1.1.1n-0+deb11u4      5
openssl         1.1.1n-0+deb11u1      DSA-5417-1  1.1.1n-0+deb11u5      4
curl            7.74.0-1.3+deb11u7    DSA-5523-1  7.74.0-1.3+deb11u10   2
curl            7.74.0-1.3+deb11u7    DLA-3951-1  7.74.0-1.3+deb11u14   1
curl            7.74.0-1.3+deb11u7    DLA-4432-1  7.74.0-1.3+deb11u16   1
curl            7.74.0-1.3+deb11u7    DSA-5587-1  7.74.0-1.3+deb11u11   1
openssl         1.1.1n-0+deb11u1      DLA-4176-1  1.1.1w-0+deb11u3      1
openssl         1.1.1n-0+deb11u1      DLA-4321-1  1.1.1w-0+deb11u4      1
openssl         1.1.1n-0+deb11u1      DSA-5139-1  1.1.1n-0+deb11u2      1
openssl         1.1.1n-0+deb11u1      DSA-5169-1  1.1.1n-0+deb11u3      1
curl            7.74.0-1.3+deb11u7    DLA-4213-1  7.74.0-1.3+deb11u15   0
--------------------------------------------------------------------------------------
14 advisories, 31 unique CVEs across 5 packages
Terminal output of the OSV patch report table


Two bugs worth knowing about

First run returned 0 advisories. The Debian ecosystem string was wrong: I grabbed the first number in the version, so openssl 1.1.1n became Debian:1. The regex needs the distro suffix, deb(\d+), not the upstream version. Second bug: one advisory listed a deb10u2 fix on a deb11 box. OSV advisories carry an affected entry per release, so the fix lookup has to pick the entry whose ecosystem matches. Same trap applies to the CVE list: Debian advisories keep their CVEs in the upstream field, and aliases alone comes back empty.


Step 4: Every CVE and the cron gate

The --full-cve flag trades the count column for the identifiers, and --check exits 1 when anything is unpatched, so a weekly cron entry fails loudly instead of emailing a report nobody reads:

python3 osv-patch-report.py packages.txt --full-cve | head -6
python3 osv-patch-report.py packages.txt --check > /dev/null; echo $?
PACKAGE         INSTALLED             ADVISORY    FIXED                 CVEs
--------------------------------------------------------------------------------------
openssl         1.1.1n-0+deb11u1      DLA-4490-1  1.1.1w-0+deb11u5      CVE-2025-68160, CVE-2025-69418, CVE-2025-69419, CVE-2025-69420, CVE-2025-69421, CVE-2026-22795, CVE-2026-22796
openssl         1.1.1n-0+deb11u1      DLA-3942-1  1.1.1n-0+deb11u6      CVE-2023-5678, CVE-2024-0727, CVE-2024-2511, CVE-2024-4741, CVE-2024-5535, CVE-2024-9143
openssl         1.1.1n-0+deb11u1      DLA-3942-2  1.1.1w-0+deb11u2      CVE-2023-5678, CVE-2024-0727, CVE-2024-2511, CVE-2024-4741, CVE-2024-5535, CVE-2024-9143
openssl         1.1.1n-0+deb11u1      DSA-5343-1  1.1.1n-0+deb11u4      CVE-2022-2097, CVE-2022-4304, CVE-2022-4450, CVE-2023-0215, CVE-2023-0286
openssl         1.1.1n-0+deb11u1      DSA-5417-1  1.1.1n-0+deb11u5      CVE-2023-0464, CVE-2023-0465, CVE-2023-0466, CVE-2023-2650
curl            7.74.0-1.3+deb11u7    DSA-5523-1  7.74.0-1.3+deb11u10   CVE-2023-38545, CVE-2023-38546
Full CVE listing and failing exit code from the check flag
# /etc/cron.weekly/osv-report (see also my cron hygiene audit)
#!/bin/bash
dpkg-query -W -f='${Package} ${Version}\n' > /tmp/packages.txt
/usr/bin/python3 /root/ops/osv-patch-report.py /tmp/packages.txt --check \
  | /usr/sbin/sendmail -s "patch report $(hostname)" [email protected]

If you want the cron job itself checked for mistakes like this, my cron and systemd timer hygiene audit script covers schedule, ownership and log wiring. And if you are wondering what is actually sitting on the other end of an unpatched openssl, the Linux TCP UAF deep dive shows how quickly a package you never think about becomes someone else’s shell. For the log side of fleet reporting, jq vs Python on a 100K-line log pairs well with this.


Ten minutes of setup, and the ‘are we patched?’ question now takes one command to answer. The punch list it prints is the shortest path from a headline CVE to the exact apt-get install --only-upgrade you need.

Mohamad ZulfahmyCybersecurity · OffSec · Entrepreneur
About me ↗
ON THIS PAGE
On this page