Article
Free CVE and end-of-life checks for a server fleet, and five traps
Ansible, endoflife.date and the NVD API can tell you which servers run end-of-life software and which versions have known CVEs, provided you avoid five traps that make a naive check report a fleet as clean when it is not.
From the case study Infrastructure for a medical-device company's clinical platform.
The problem
You run a dozen or a hundred Docker hosts. Someone asks whether anything is end of life, or whether you are affected by a new CVE. A proper answer needs three facts per component: which version runs where, whether that release line is still supported, and which published CVEs apply to it.
Commercial scanners answer this for a fee, and most want an agent on every host or read access to your registry. For a small fleet there is a free route:
- Ansible, or any SSH loop, to collect versions;
- endoflife.date for release lines, end-of-life dates and the latest patch;
- the NVD API for CVEs, queried by CPE, the standard product identifier.
The wiring is short. The traps are where the work is. A first attempt we built for a client fleet returned 11,000 operating-system rows with no severity on any of them, and no findings at all for the application components, on a fleet where several components were end of life. We rebuilt it around the rules below.
The pattern
- Collect
{host, container, platform, version}records from every host you can reach. - Ask endoflife.date about each version's release line.
- Route by platform kind: only upstream applications get a CVE lookup.
- Re-check each CVE NVD returns against its own applicability data.
- Report every result as
vulnerable,clean,not-applicableorunknown, with a reason.
Step 1: collect versions without trusting tags
If images are tagged by version (postgres:17.9), the tag answers the question. Many teams tag custom images by environment (web:prod, nginx:uat), and then the tag tells you nothing. Ask the running container, and keep one record per container: a web and a worker container built from the same base can sit on different patch releases.
A probe that prefers a version-shaped tag and otherwise asks the binary:
#!/usr/bin/env bash
# Prints one JSON line per recognised container: name, platform, version
version_of() { # $1 = platform, $2 = container
case "$1" in
nginx) docker exec "$2" nginx -v 2>&1 ;;
php) docker exec "$2" php -r 'echo PHP_VERSION;' ;;
redis) docker exec "$2" redis-server --version ;;
postgres) docker exec "$2" postgres --version ;;
db) docker exec "$2" sh -c 'mariadbd --version 2>/dev/null || mysqld --version' ;;
esac | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -1
}
docker ps --format '{{.Names}} {{.Image}}' | while read -r name image; do
repo="${image##*/}"; repo="${repo%%[:@]*}"
case "$repo" in nginx|php|redis|postgres) plat="$repo" ;; mysql|mariadb) plat=db ;; *) continue ;; esac
tag="${image##*:}"
if [[ "$plat" != db && "$tag" =~ ^[0-9]+\.[0-9]+ ]]; then ver="${tag%%-*}"; else ver="$(version_of "$plat" "$name")"; fi
[ -n "$ver" ] && printf '{"name":"%s","platform":"%s","version":"%s"}\n' "$name" "$plat" "$ver"
done
Run it from an Ansible role and assemble results on the control node from the hosts that answered. Read any per-run settings from play variables. If they come from the first host's variables and that host is unreachable, the whole run fails.
Step 2: endoflife.date for currency and the upgrade target
For each product, endoflife.date lists every release line with its end-of-life date and latest patch. Its v1 product endpoint also carries the product's CPE, so you do not have to keep a product-to-CPE map by hand.
The upgrade rule fits in a few lines: an end-of-life line moves to the latest supported release, otherwise take the latest patch on the same line.
import json, urllib.request
def vtuple(v):
return tuple(int(p) for p in v.split(".") if p.isdigit())
def eol_advice(product, version):
url = f"https://endoflife.date/api/v1/products/{product}"
data = json.load(urllib.request.urlopen(url, timeout=20))["result"]
cpe = next((i["id"] for i in data.get("identifiers", []) if i.get("type") == "cpe"), None)
lines = [r for r in data["releases"] if version == r["name"] or version.startswith(r["name"] + ".")]
line = max(lines, key=lambda r: len(r["name"])) # most specific: 10.11 over 10
newest = next(r for r in data["releases"] if not r["isEol"])
if line["isEol"]:
return cpe, newest["latest"]["name"], f"line {line['name']} is end of life"
latest = line["latest"]["name"]
if vtuple(version) < vtuple(latest):
return cpe, latest, f"patch available on {line['name']}"
return cpe, None, f"up to date on {line['name']}"
The v1 API is labeled beta. Pin the fields you read and watch for changes.
Trap 1: OS packages carry backported fixes
A tempting shortcut is to send the OS release to a vulnerability database under the nearest ecosystem it knows, such as Debian for an Ubuntu host. Debian and Ubuntu version their packages differently. Distributions also backport security fixes into old upstream versions without changing the version number, so a package that looks vulnerable by its upstream version is often patched. The result is thousands of rows with no action attached to any of them.
Route by platform kind instead. Each kind has its own source of truth:
- Operating system release: end of life and currency only. Per-package CVEs need a scanner that reads the distribution's own advisories; run that as a separate step.
- Managed engine (a cloud database service): currency only. The provider patches it.
- Upstream application (nginx, PHP, Redis, Postgres): CVE lookup by CPE.
The same caution applies inside containers: software installed from the distribution's packages (apt install nginx) carries backports too. Treat upstream CVE matches on distro-packaged software as candidates for review.
Trap 2: NVD's match-all CPEs
Query NVD with a full CPE name (cpe:2.3:a:php:php:8.3.30:*:*:*:*:*:*:*) and it returns every CVE whose applicability statement matches. Some statements are ...:php:php:* with no version bounds, which match every version ever released, and that is how a CVE from 2007 comes back for PHP 8.3.
Re-check each CVE yourself. Keep it only if a vulnerable match for the same vendor and product bounds your version with a range, or pins a version on your release line:
def applies(match, vendor, product, version):
if not match.get("vulnerable"):
return False
parts = match["criteria"].split(":") # cpe:2.3:a:vendor:product:version:...
if (parts[3], parts[4]) != (vendor, product):
return False
v = vtuple(version)
bounds = {
"versionStartIncluding": lambda b: v >= b, "versionStartExcluding": lambda b: v > b,
"versionEndIncluding": lambda b: v <= b, "versionEndExcluding": lambda b: v < b,
}
present = [(k, f) for k, f in bounds.items() if k in match]
if present:
return all(f(vtuple(match[k])) for k, f in present)
pinned = parts[5]
if pinned in ("*", "-", ""):
return False # unbounded match-all: drop
return vtuple(version)[:2] == vtuple(pinned)[:2] # same release line
Skip CVEs whose status is Rejected. Unit-test four cases with canned JSON: a match-all dropped, a containing range kept, a pinned version on the same line kept, a different product ignored. What survives is a list of candidates for a person to triage, and the report should say that.
Trap 3: a failed lookup reported as clean
NVD rate-limits hard (5 requests per 30 seconds without an API key, 50 with one) and returns 503 now and then. If a failed request turns into an empty list, every throttled lookup becomes a clean component, and the report tells people they are safe when the question was never answered.
Make failure its own result:
def cve_status(platform_kind, cpe, version, fetch):
if platform_kind in ("os", "managed"):
return "not-applicable", "currency tracked; no upstream CVE lookup"
if not cpe:
return "unknown", "no CPE for this product"
try:
cves = fetch(cpe, version) # raises on HTTP errors and timeouts
except Exception as e:
return "unknown", f"lookup failed: {e}"
return ("vulnerable", f"{len(cves)} candidate CVEs") if cves else ("clean", "no applicable CVEs")
Count unknowns separately from clean results. Around the lookup: retry 408, 429 and 5xx with exponential backoff, cache responses on disk so reruns and tests do not hit the API, get the free NVD API key, and read totalResults so you paginate or at least flag a truncated page.
Trap 4: classifying by the image name
An audit column can read "MySQL 10.x". MySQL has no 10.x line: the container is MariaDB, built from a directory or image repository named mysql. A collector that classifies by repository name will then look up MySQL 10.x, find nothing, and report clean on what is in fact an end-of-life database.
Classify by what the binary prints. In the probe above, database containers always take the exec path, and the db platform is resolved from the version output before any lookup:
out="$(docker exec "$name" sh -c 'mariadbd --version 2>/dev/null || mysqld --version')"
case "$out" in *MariaDB*) plat=mariadb ;; *) plat=mysql ;; esac
Trap 5: coverage left out of the report
Hosts behind another VPN or bastion will not answer. If the report lists only the hosts that did, it reads as complete. State coverage first (for example, "9 of 14 hosts collected") and list the missing hosts by name, so a reader knows what the report does not cover.
We used this in Infrastructure for a medical-device company's clinical platform.
Related articles
Crash forensics for a cloud VM in one Ansible run
A read-only Ansible playbook, written once and run the moment a failed VM comes back, collects the evidence for the usual causes before it rotates away, and on a client's production server it pointed to CPU-credit throttling.
Verified Postgres TLS when no host chains to a public root
If your app connects to Postgres with
rejectUnauthorized: false, the connection is encrypted but the server's identity was never checked. Here is how to turn verification on when the certificate is signed by a private CA, without taking production down.Silent zeros: four times a pipeline said success and was not
A batch job that crashes is easy to deal with: something goes red and someone looks. The harder case is a job that runs, exits 0, posts a green tick, and produced nothing, or produced yesterday's data again, or checked nothing at all. We call these silent zeros.
Running into something like this?
Thirty minutes, no commitment. Bring the problem; we will tell you what we would check first and whether we are the right people for it.