EOL gating — when a CVE means migrate, not patch

A CVE on a maintained component has a fix path — bump the version, re-run the build, ship. A CVE on an end-of-life (EOL) component has no fix path, ever. The upstream isn’t shipping patches anymore. Bulk-fixing CVEs on an EOL’d runtime or distro is whack-a-mole; the only durable outcome is migration.

EOL detection is therefore an SSVC-level concern. It changes Remediation Option to NO_PATCH (or PATCH_UNAVAILABLE if a community fix is in flight). It changes the right Engineer Triage outcome from NIGHTLY_AUTO_PATCH to SPIKE_EFFORT (plan migration) — and it changes the unit of work from “this CVE” to “this entire stack.”

For terminology used here, see the Glossary.

What goes EOL

Three layers, distinct lifecycles, distinct evidence sources:

  1. Language runtimes — Python 2.7, Node.js 14/16, OpenJDK 8 (free Oracle JDK; commercial vendors continue), Go versions older than latest-2, Rust edition support.
  2. Packages / libraries — npm packages with no commits for years, Maven artefacts the maintainer publicly stopped maintaining, Python packages explicitly deprecated, Linux distro packages dropped from a release.
  3. Container base images / OS distros — Debian releases past their LTS window (Debian 10 = LTS-EOL 2024-06; Debian 9 = ELTS), Ubuntu non-LTS releases past 9 months, Alpine maintenance branches, RHEL major-version lifecycle, Amazon Linux 1 (EOL 2023).

Data sources for EOL

SourceWhat it coversNotes
endoflife.dateSingle biggest cross-language EOL catalogue — Python, Node, Java, Go, Ruby, PHP, distros, frameworks, databasesFree, well-maintained, JSON API at https://endoflife.date/api/<product>.json. Most scanners that surface EOL ultimately query this.
python-eolPython-only, with per-minor end datesPython 3.x release schedule details
Node.js release scheduleActive LTS / Maintenance / End-of-LifeAuthoritative for Node
OpenJDK lifecycleJava versions; distinct vendor lifecycles (Oracle, Eclipse Temurin, Amazon Corretto, Azul Zulu)Free vs commercial timelines differ
Debian releasesPer-release EOL dates including LTS and ELTS extensionsDebian’s LTS volunteers extend support, ELTS via Freexian commercial
Ubuntu release scheduleLTS (5y) / interim (9mo)ESM for older LTS via Ubuntu Pro
Alpine releasesPer-version maintenance windowsTwo-year support window typical
RHEL lifecycleFull support / maintenance / ELS phasesUBI follows the underlying RHEL lifecycle
Amazon Linux lifecycleAL1 EOL; AL2 / AL2023 active
Vulnetix VDBPer-package + per-runtime + per-base-image lifecycleStage fieldFirst-party — integrates the above sources plus first-party EOL judgements on packages

Runtime EOL signalling — what the runtime tells you

Most runtimes don’t proactively warn that they’re EOL. Notable exceptions:

  • Python 2.7 prints DeprecationWarning: Python 2 is no longer supported on import of pip in current versions.
  • Node.js logs (node:1) NodeAgentLoader: Node.js LTS support ended on YYYY-MM-DD on startup for unsupported versions.
  • Java prints warnings for preview features past their preview window, but not for the whole runtime.
  • Most distros: silent. You need to check the version against the lifecycle table.

In practice, build your gate at scan time rather than relying on runtime warnings:

# Python runtime EOL gate
PY_VERSION=$(python3 --version | cut -d' ' -f2 | cut -d. -f1-2)
PY_EOL=$(curl -s "https://endoflife.date/api/python.json" \
  | jq -r --arg v "$PY_VERSION" '.[] | select(.cycle == $v) | .eol')
[ "$(date -u +%Y-%m-%d)" \> "$PY_EOL" ] && {
  echo "Python $PY_VERSION is EOL ($PY_EOL) — block deploy"; exit 1; }

# Node.js
NODE_MAJOR=$(node --version | cut -dv -f2 | cut -d. -f1)
NODE_EOL=$(curl -s "https://endoflife.date/api/nodejs.json" \
  | jq -r --arg v "$NODE_MAJOR" '.[] | select(.cycle == $v) | .eol')

# Container base image EOL — read from inside the image
DISTRO_VERSION=$(docker run --rm myapp:tag sh -c '. /etc/os-release && echo "$ID-$VERSION_ID"')
# Then look up against endoflife.date or the distro's table.

The SSVC mapping

EOL changes the Remediation Option directly:

Component statusRemediation OptionNotes
Maintained, fix available in version range your manifest acceptsPATCHABLE_DEPLOYMENTNormal case
Maintained, fix available but your version is pinnedPATCHABLE_VERSION_LOCKEDBump and test
Maintained, fix available but breaks APIPATCHABLE_MANUALPort the fix
Maintained, no fix yet — vendor is working on onePATCH_UNAVAILABLEWait + mitigate
EOL — vendor stopped publishing patchesNO_PATCHMigrate
EOL — community/distro is back-porting (Debian LTS, RHEL ELS)PATCH_UNAVAILABLE (for now) shading toward NO_PATCH (eventually)Time-limited fix path

The Engineer Triage outcome for NO_PATCH is rarely NIGHTLY_AUTO_PATCH (there’s nothing to auto-patch). It’s usually SPIKE_EFFORT (plan the migration in the next sprint) or BACKLOG if the priority is low enough to live with — though “live with” on a critical CVE in EOL software is a hard sell to anyone reviewing the decision.

Worked example — Python 2.7 EOL with a critical CVE

A legacy service on Python 2.7. A scanner flags CVE-2024-NNNNN against a transitive dep urllib3. The fix exists in urllib3>=2.0, but urllib3>=2.0 requires Python 3.8+.

Engineer Triage inputs:

  • Reachability = VERIFIED_REACHABLE (the service makes outbound HTTPS calls through urllib3).
  • Remediation Option = NO_PATCH (urllib3’s 1.x line is EOL; the only fix is on the 2.x line, which doesn’t support Python 2.7).
  • Mitigation Option = INFRASTRUCTURE (egress proxy with TLS validation, mTLS, or CODE_CHANGE to swap urllib3 for requests 2.27 + a vendor-specific HTTP client — also Python-2-incompatible).
  • Priority = HIGH or CRITICAL depending on the CVE.

Outcome: SPIKE_EFFORT. The unit of work isn’t “bump urllib3” — it’s “migrate the service off Python 2.7.” Any per-CVE patching effort spent on this service is wasted; the next CVE will arrive in weeks. Record the EOL evidence in the VEX:

"analysis": {
  "state": "affected",
  "response": ["will_not_fix"],
  "detail": "Engineer Triage: SPIKE_EFFORT. Inputs: reachability=VERIFIED_REACHABLE, remediation=NO_PATCH (Python 2.7 reached EOL 2020-01-01; urllib3 2.x is the only fixed line and requires Python 3.8+), mitigation=INFRASTRUCTURE (egress proxy with TLS validation pending migration), priority=HIGH. Migration to Python 3.11 tracked in PROJ-1234 (target FY26-Q1)."
}

Container base image EOL — same shape, different blast radius

When a container base image reaches EOL, every dependent image inherits the problem. A FROM debian:10-slim post-LTS gets no security updates for the OS layer. Every CVE on libc6, openssl, bash etc. becomes a NO_PATCH per the above table.

Three escalating fix options (mirrors Grype’s Class A):

  1. Bump to a still-supported tag of the same distro: debian:10debian:12. Cheapest; usually breaks one or two things (library version bumps, locale config).
  2. Migrate to a maintained alternative: Debian 10 → Red Hat UBI / Chainguard Wolfi / Google distroless / Alpine. More breakage, smaller attack surface ongoing.
  3. Pay for extended support: Debian Freexian ELTS, Ubuntu Pro ESM, RHEL ELS. Buys time; doesn’t solve the structural problem.

A scanner without native EOL data won’t flag the base image as the root cause — it’ll flag the dozens of individual OS-package CVEs. Pivot via:

# Identify the base image's OS version from inside the image
docker run --rm <image> cat /etc/os-release

# Look up EOL
curl -s "https://endoflife.date/api/debian.json" \
  | jq '.[] | select(.cycle == "10") | {release: .releaseDate, eol: .eol, lts: .extendedSupport}'

# Vulnetix-native: read lifecycleStage per OS finding
vulnetix vdb vuln <CVE> --output json \
  | jq '.[0].containers.adp[0].x_remediationTimeline.lifecycleStage'

Per-tool applicability — EOL coverage

ToolRuntime EOLPackage EOLContainer base-image EOL
Vulnetix✅ Native via vdb lifecycleStage✅ Native✅ Native — vulnetix:eol-check skill plus --block-eol gate
Grype❌ No native EOL; OS feed surfaces “no fix available” which is a weak signal❌ Same🟡 Inferred — when an OS package is past upstream support, the feed will lack a fixed version, which approximates EOL
Snyk OSS🟡 Some EOL signalling in commercial tiers🟡 Same🟡 Container-image scanning surfaces base-image age (commercial)
Dependabot❌ No EOL signalling❌ Surfaces deprecated-package advisories where the maintainer has published one, but no proactive lifecycle❌ Not a container scanner
OSV-Scanner
GitLab Dependencies🟡 GitLab Container Scanning checks; lifecycle is implicit
Semgrep/Opengrep❌ (SAST scope)
CodeQL❌ (SAST scope)

native first-party EOL data; 🟡 partial / commercial-tier-only / inferred; not covered, cross-reference endoflife.date or Vulnetix.

The database quality tiers feed EOL the same way they feed CVE coverage — the broader the source set, the more EOL signals are picked up automatically.

When community / distro backporting changes the answer

Some upstream-EOL components keep getting fixes from third parties:

  • Debian LTS / ELTS: Debian volunteers cover non-current releases for two extra years; Freexian’s ELTS extends for several more. apt-get install fetches patched versions even though the upstream Debian release is “EOL.”
  • Ubuntu Pro / ESM: paid commercial extension of LTS support.
  • RHEL ELS: Red Hat’s Extended Life-cycle Support.
  • AlmaLinux / Rocky Linux: community RHEL rebuilds with their own backporting cadence.
  • Independent community forks: occasionally a maintainer-fork picks up an abandoned package (rare, fragile).

For these, the Remediation Option is PATCH_UNAVAILABLE (a fix is in flight from a third party, but not from upstream) shading toward NO_PATCH as the third-party window closes. Record the third-party patch path in the VEX action_statement so future-you knows where the fix came from.

See also