What this check reads
The check pulls every tagged local image with docker images --format '{{.Repository}}:{{.Tag}}', drops the <none> entries, and runs the resulting repo:tag strings through one fixed regex. That pattern covers ubuntu:14.04|16.04|18.04 and the trusty, xenial and bionic codenames, debian:7|8|9 and wheezy, jessie and stretch, centos:6|7|8, alpine:3.9-3.12, node:10|12|14, and python:2.7|3.5|3.6. A single matching tag is enough to FAIL, with the first 10 hits named in the message; no match anywhere in the list is the PASS, and no tagged images at all is the SKIP.
When it applies
Needs the docker binary and daemon access; otherwise SKIP. Unlike the other image checks it examines every tagged local image, not a sample, but reports at most 10 matches. Matching is a single fixed regex over the repo:tag string - it never inspects the image contents - so it only finds distributions named in the tag. An image whose tag hides the base (myapp:1.4.2 built FROM debian:9) is invisible, as is any runtime not in the list.
Known accuracy issue (verified August 2026): the pattern is a hard-coded snapshot and is now well out of date. It covers Ubuntu 14.04/16.04/18.04, Debian 7/8/9, CentOS 6/7/8, Alpine 3.9 - 3.12, Node 10/12/14 and Python 2.7/3.5/3.6. It therefore misses everything that has since reached end of life, including PHP 8.1, Node.js 16/18/20, MySQL 5.7 and 8.0, PostgreSQL 12 and 13, MongoDB 5.0 and 6.0, Redis 6.x and 7.0/7.2, Ubuntu 20.04, Debian 10, Alpine 3.13 - 3.20 and Python 3.7 - 3.9. A PASS here means "no tag matched a 2022-era list", not "everything is supported". This is the known issue flagged at the top of this page.
What each result means
| Result | When | What it means |
|---|---|---|
| SKIP | Docker not installed, daemon unreachable, or no local images | Nothing was verified. No image list reached the pattern, so no statement was made about the support status of anything you run. |
| FAIL | Any tag matches the EOL list | At least one image is tagged with a distribution or runtime version that has stopped receiving security patches, and the message names it. |
| PASS | None match (with a reminder to verify manually) | No tag matched this frozen list, which is much weaker than being supported. Anything that reached end of life after the pattern was written passes here. |
Why it matters
An EOL base image receives no security updates, so every CVE found after the EOL date stays open forever, and scanners often cannot even report them (the distro stops publishing advisories). CIS Docker 4.2/4.4; NIST SP 800-190 4.1.1; OWASP Top 10 A06:2021.
Why it fails, and when it is wrong
- The EOL list is incomplete (verified August 2026). Not flagged but EOL as of this date:
ubuntu:20.04(standard support ended 31 May 2025; ESM only),debian:10(June 2024),debian:11(LTS ended 31 August 2026),centos:stream8(May 2024),alpine:3.13through3.20(Alpine supports each release for about two years; 3.20 ended April 2026),node:16,node:18(April 2025),node:20(April 2026),python:3.7,python:3.8(October 2024),python:3.9(October 2025),php:7.*,php:8.0,php:8.1,ruby:3.0/3.1,golangolder minors,openjdk:8/11(OpenJDK project images deprecated entirely). Consider generating the pattern from endoflife.date at release time, or usingdocker scout recommendations/ Trivy's--scanners vulnEOSL warning ("OSEndOfLife"/ "This OS version is no longer supported"). - Only the tag string is examined. An image built
FROM ubuntu:18.04but taggedmyapp:2.1passes. Trivy and Scout detect the OS from the layers (trivy imageprints "Detected OS" and an EOSL warning). centos:7containers are still common for legacy apps; CentOS 7 ended 30 June 2024.- Images of a current distro with a stale minor are not EOL, just out of date (covered by the vulnerability scan).
How to fix it
Find the real base of every image
A tag such as myapp:2.1 hides the distribution underneath, so read it out of the image.
for i in $(docker images --format '{{.Repository}}:{{.Tag}}' | grep -v '<none>'); do
printf '%s -> %s\n' "$i" "$(docker run --rm --entrypoint sh "$i" -c 'grep PRETTY_NAME /etc/os-release' 2>/dev/null)"
doneCheck each one against endoflife.date
Check the distribution and the language runtime, since either can run out of support first.
curl -s https://endoflife.date/api/debian.json | head -40
curl -s https://endoflife.date/api/nodejs.json | head -40Rebuild on a supported base
FROM debian:bookworm-slimOr ubuntu:24.04, alpine:3.22, node:22-slim, python:3.13-slim, or a distroless or Chainguard equivalent. Package names and library versions move with the base, so run the test suite before shipping. In CI, add trivy image --exit-code 1 --severity CRITICAL and treat trivy's unsupported-OS warning as a failure.
Remove the old image
docker compose up -d --build
docker rmi <old-image>:<tag>The check reads the local image store, so an old tag left behind keeps the FAIL alive after every container has moved on.
Verify the fix
# Reproduce the check's own match:
docker images --format '{{.Repository}}:{{.Tag}}' | grep -v '<none>' | \
grep -E 'ubuntu:(14|16|18)\.04|ubuntu:(trusty|xenial|bionic)|debian:(7|8|9)($|-)|debian:(wheezy|jessie|stretch)|centos:(6|7|8)($|-)?|centos:centos(6|7|8)|alpine:3\.(9|10|11|12)($|\.)|node:(10|12|14)($|-)|python:(2\.7|3\.5|3\.6)($|-)'
# Find the real base of every image, including ones whose tag hides it:
for i in $(docker images --format '{{.Repository}}:{{.Tag}}' | grep -v '<none>'); do
printf '%s -> %s\n' "$i" "$(docker run --rm --entrypoint sh "$i" -c 'cat /etc/os-release 2>/dev/null | grep PRETTY_NAME' 2>/dev/null)"
done
# Check each against the current lifecycle data rather than the frozen list:
curl -s https://endoflife.date/api/ubuntu.json | head -40
curl -s https://endoflife.date/api/nodejs.json | head -40Debugging
Three different cases share this status. 'Docker not installed' is correct on a host with no Docker. 'Cannot query Docker daemon' means the audit account could not reach the daemon: check systemctl is-active docker, then docker ps as that user and sudo docker ps, and run the audit as root or with sudo if only the second works. 'No local images found' means the daemon held no tagged images, which is normal on a host that pulls at deploy time; check with docker images -a. It is also what you get when the audit has root but the daemon is down, because an empty answer and an empty image list look the same to the script. The Docker Inventory check covers the unreachable case in more detail.
The central issue with this check. The list is frozen and misses every runtime that reached end of life after it was written. Use the os-release loop and endoflife.date above to check what you actually run; do not read a PASS as "supported".
A vendor image stuck on an old base is a supply-chain decision, not a config one. Escalate it with the vendor, isolate the container tightly (non-root, read-only, dropped capabilities, no socket), and record the exception.
Old base images linger in the local store. docker image prune -a removes what nothing references; confirm with docker ps -a --format '{{.Image}}' first.
The check can only read tags. Inspect the base with the os-release loop, or docker history <image> to see the build lineage.
The old image is still in the local store under its old tag. Remove it (docker rmi <old>) and confirm running containers use the new one.
Sources
- endoflife.date: Ubuntu
- endoflife.date: Debian
- endoflife.date: CentOS
- endoflife.date: Alpine
- endoflife.date: Node.js
- endoflife.date: Python
- endoflife.date: PHP
- Trivy: Unsupported OS / EOSL detection
- Docker Scout: recommendations (base image updates)
- Docker Official Images: deprecated images policy
- Ubuntu release cycle
- Debian LTS
- Alpine Linux releases
How the script reads this
Next
Re-run the Image Vulnerabilities audit after applying the fix and confirm this check moves to PASS. CtrlOps runs all 8 checks over your existing SSH connection and scores the result, so the change is visible without reading another config file.
All 8 Image Vulnerabilities fixes