CVEs, SBOM & supply chainintermediate5 min read

Finding CVEs in your Ubuntu and Debian packages with OSV.dev — without Nessus

A forty-line script that checks every installed package against the free OSV.dev API, backport-aware, and enriches the list with EPSS score and CISA KEV status. From 1,800 packages to the five you need to patch this week. No licence, no scanner appliance.

Contents
  1. Step 1: the inventory the way OSV wants it
  2. Step 2: querying OSV.dev in batches
  3. Step 3: from OSV id to CVE, severity and fix version
  4. Step 4: prioritising with EPSS and KEV
  5. Step 5: automate and keep
  6. Pitfalls
  7. What you still don't have
  8. How monsys does it
  9. FAQ

A vulnerability scanner that sees nginx 1.24.0 and reports 14 CVEs is usually wrong: Ubuntu and Debian backport fixes into the existing version, and 1.24.0-2ubuntu7.3 contains the patch the scanner misses. The result is a report with hundreds of lines where nobody knows which ones are real. This article does it the other way round: start from what dpkg knows, ask OSV.dev — which uses the Ubuntu Security Notices and the Debian Security Tracker as its source — and enrich only what remains.

Step 1: the inventory the way OSV wants it

OSV knows Ubuntu and Debian packages by the name of the source package, with the ecosystem string Ubuntu:24.04 or Debian:12. The binary name (libssl3t64) is not what you want; the source package (openssl) is. dpkg-query can produce that directly:

# Ubuntu 24.04 → "Ubuntu:24.04"; Debian 12 → "Debian:12"
. /etc/os-release
case "$ID" in
  ubuntu) ECO="Ubuntu:$VERSION_ID" ;;
  debian) ECO="Debian:$VERSION_ID" ;;
  *) echo "unsupported: $ID"; exit 1 ;;
esac

# source package + version, deduplicated (one source often yields 5 binaries)
dpkg-query -W -f='${source:Package} ${source:Version}\n' | sort -u > /tmp/pkgs.txt
wc -l /tmp/pkgs.txt      # typically 400-900 source packages on a server

Mind the version: 1:2.4.7-1ubuntu5 — that 1: is the epoch and belongs there. OSV compares exactly according to Debian version rules; leave it in.

Step 2: querying OSV.dev in batches

The endpoint https://api.osv.dev/v1/querybatch takes up to 1,000 queries per call, without an API key. The response contains, per query, the list of OSV ids (UBUNTU-CVE-…, USN-…, DSA-…, DLA-…) for which your version is still vulnerable — a fixed backport doesn't count, because the USN says which version contains the fix.

sudo apt install -y jq curl
# Build the JSON body (max 1000 per batch; we split)
split -l 900 /tmp/pkgs.txt /tmp/pkgbatch.
: > /tmp/osv-raw.jsonl
for f in /tmp/pkgbatch.*; do
  jq -Rn --arg eco "$ECO" '
    {queries: [inputs | split(" ") | {package: {name: .[0], ecosystem: $eco}, version: .[1]}]}' "$f" \
  | curl -s -X POST https://api.osv.dev/v1/querybatch -H 'Content-Type: application/json' -d @- \
  | jq -c --slurpfile q <(jq -Rn '[inputs | split(" ")]' "$f") '
      .results | to_entries[] | select(.value.vulns != null)
      | {pkg: $q[0][.key][0], version: $q[0][.key][1], vulns: [.value.vulns[].id]}' \
  >> /tmp/osv-raw.jsonl
done
jq -r '"\(.pkg) \(.version) \(.vulns|length)"' /tmp/osv-raw.jsonl | sort -k3 -rn | head -20

On an up-to-date Ubuntu 24.04 server the list is short: a handful of packages for which Ubuntu hasn't released a fix yet (or which sit in universe, where there's no security support). On a server that hasn't been patched in three months, it's long. Both are exactly the answer you want.

Step 3: from OSV id to CVE, severity and fix version

A USN or UBUNTU-CVE id tells you nothing about severity. Fetch the details per vulnerability — the CVE number (from the aliases, or from the id itself for UBUNTU-CVE-…), severity, and the version it's fixed in:

: > /tmp/osv-detail.jsonl
for id in $(jq -r '.vulns[]' /tmp/osv-raw.jsonl | sort -u); do
  curl -s "https://api.osv.dev/v1/vulns/$id" | jq -c --arg eco "$ECO" '{
    id,
    cve: (((.aliases // []) + [.id | ltrimstr("UBUNTU-")]) | map(select(startswith("CVE-"))) | first),
    severity: ((.database_specific.severity // .severity[0].score // "unknown") | tostring),
    fixed: ([ .affected[] | select(.package.ecosystem == $eco) | .ranges[]?.events[]? | .fixed? // empty ] | first),
    summary }' >> /tmp/osv-detail.jsonl
  sleep 0.1   # be polite; OSV has no hard rate limit but does have an ops team
done
jq -r '[.id, (.cve // "-"), .severity, (.fixed // "no fix")] | @tsv' /tmp/osv-detail.jsonl | column -t

The fixed field is the key: if there's a version, apt upgrade is the fix. If it says no fix, it's an open vulnerability for which you need to document a mitigation or a risk acceptance.

Step 4: prioritising with EPSS and KEV

A list of 60 CVEs is still not a work plan. Two free sources turn it into one:

  • EPSS (FIRST.org): the probability that a CVE will be exploited in the next 30 days, 0–1. Above 0.1 is exceptional.
  • KEV (CISA Known Exploited Vulnerabilities): CVEs with confirmed active exploitation. If a CVE is in here, the discussion is over.
# KEV: one JSON, refreshed daily
curl -s https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json \
  | jq -r '.vulnerabilities[].cveID' | sort -u > /tmp/kev.txt

# EPSS: batch of max 100 CVEs per call
jq -r '.cve // empty' /tmp/osv-detail.jsonl | sort -u > /tmp/cves.txt
: > /tmp/epss.tsv
split -l 100 /tmp/cves.txt /tmp/cvebatch.
for f in /tmp/cvebatch.*; do
  curl -s "https://api.first.org/data/v1/epss?cve=$(paste -sd, "$f")" \
    | jq -r '.data[] | [.cve, .epss] | @tsv' >> /tmp/epss.tsv
done

# Join: CVE, EPSS, KEV, package, fix
jq -r 'select(.cve) | [.cve, .id, (.fixed // "no fix")] | @tsv' /tmp/osv-detail.jsonl \
| while IFS=$'\t' read -r cve osv fix; do
    epss=$(awk -v c="$cve" '$1==c{print $2}' /tmp/epss.tsv); epss=${epss:-0}
    kev=$(grep -qx "$cve" /tmp/kev.txt && echo KEV || echo -)
    printf '%s\t%s\t%s\t%s\t%s\n' "$cve" "$epss" "$kev" "$osv" "$fix"
  done | sort -k3,3r -k2,2gr | column -t

The sort order is deliberate: everything in KEV first, then descending EPSS. The top five lines are your week. The bottom forty you can safely leave until the next patch round — and that is a decision you can explain in an audit.

Step 5: automate and keep

Put steps 1–4 in one script, run it weekly and keep the output with a date. Two reasons: you want to see since when a CVE has been open (for an auditor's MTTR question), and you want to know whether something new appeared or the list merely shifted.

sudo install -d /var/lib/cve-scan
echo '15 6 * * 1 root /usr/local/sbin/cve-scan.sh > /var/lib/cve-scan/$(date +\%F).tsv 2>/var/lib/cve-scan/last-error.log' \
  | sudo tee /etc/cron.d/cve-scan

# What's new since last week?
comm -13 <(cut -f1 /var/lib/cve-scan/2026-09-08.tsv | sort) <(cut -f1 /var/lib/cve-scan/2026-09-15.tsv | sort)

Pitfalls

  • universe has no security support on Ubuntu. Packages from universe (check with apt-cache policy <pkg>) get no USNs, so OSV sees "no fix" while upstream patched long ago. Ubuntu Pro (free up to 5 machines) covers universe via ESM.
  • Debian's tracker knows <unfixed> and <no-dsa>. A CVE with no-dsa has been assessed by Debian as low risk and will only be fixed in the next point release. Don't ignore, but weigh differently.
  • Multiple versions of the same source package. After a partial upgrade you can have libssl3t64 on the new and openssl on the old version. sort -u on source+version shows both; apt list --upgradable tells you why.
  • You don't see snap and pip packages. dpkg knows nothing about snap list or pip list. Application dependencies (npm, pip, composer) need a separate scan with the ecosystem strings npm, PyPI, Packagist.
  • The kernel is a story of its own. linux-image-* is in the list, but the question "am I running the patched kernel" is about uname -r, not about dpkg. See kernel CVEs and backports.

What you still don't have

  • Fleet view. Thirty servers are thirty .tsv files. "Which hosts still have CVE-2026-1234 open?" is a grep -l over ssh.
  • Context. A critical CVE in libxml2 on an internal build server weighs differently from the same CVE on the reverse proxy exposed on port 443. The script doesn't know which host is internet-facing.
  • History and evidence. The tsv files give you "since when", but not in a form an auditor accepts (signed, immutable, with the decision "accepted risk, because …" attached).

How monsys does it

The monsys agent ships the package inventory (source package + version) to the hub; the OS package CVE worker checks it against OSV.dev and enriches every match with EPSS and KEV, exactly as above. On top comes what a script can't do: a hop distance to the internet from the network topology (a CVE on an internet-facing host ranks higher), a resolution log recording when a CVE disappeared so MTTR is measurable, and a signed audit pack every month. For packages you deliberately don't patch, you record a VEX statement — with a date.

FAQ

Is OSV.dev reliable for Ubuntu and Debian?

Yes. OSV imports the Ubuntu Security Notices and the Debian Security Tracker directly, including the fixed versions. It's the same data that ubuntu-security-status and debsecan use, but through one API for both distributions.

Why does my scanner report more CVEs than this script?

Because most scanners compare the upstream version (nginx 1.24.0) and don't know that 1.24.0-2ubuntu7.3 contains a backport of the fix. That's a false positive from missing backport knowledge; OSV has that knowledge because the distribution supplies it itself.

How often should I run this?

Weekly is a good minimum for a server fleet; KEV is updated daily, so on a critical news item (a new OpenSSH or kernel CVE) you run it ad hoc. Keep every run — the history is worth more than the latest state.

Written by the monsys team — sysadmins who do this every day.

Done it by hand? Let monsys keep it running.

Everything in this guide runs in monsys as a continuous check, with history, alerts and audit evidence. 5 servers free, EU-hosted in Belgium, installed in 60 seconds.