endoflife.ai
EOL Checker Products EOL Watch Get Support

Audit an SBOM for End-of-Life Software

Four real SBOMs, 21,347 components, and why name matching is the wrong tool.

By Scott Bissett  ·  Published: September 5, 2026  ·  Reference guide — SBOM lifecycle enrichment  ·  Every count below comes from a run we can reproduce; every date is bound to the same data as our product pages — methodology

A software bill of materials answers one question well: what is in this build? It answers a second badly: is any of it still supported? Vulnerability scanners join an SBOM to CVE databases and report the flaws already found. End of life is the flaw not yet found — the component that will get no fix when the next one is. This guide makes that second join reliable, and shows what it finds on four real SBOMs exported from public GitHub repositories on September 5, 2026.

The one rule: match by package URL, never by name. A purl is exact or it is nothing; a name is a guess dressed up as a match. Everything below follows from that.

Why the join key has to be the purl

Every modern SBOM generator — Syft, Trivy, cdxgen, the CycloneDX and SPDX toolchains, GitHub's dependency-graph export — identifies each component with a package URL: a string like pkg:npm/%40nestjs/[email protected] or pkg:docker/library/[email protected] that names the ecosystem, the namespace, the package and the version in one unambiguous form. The component's name field, by contrast, is whatever the packager wrote down. Four things go wrong when you match on it:

The purl resolves all four: the type says which ecosystem's rules apply, the namespace disambiguates the name, and normalisation (lowercase, %40 to @, PyPI underscores to hyphens, version and qualifiers stripped) yields one key per package. Our rule on top of the spec is the honest one: a component that carries a purl resolves only by that purl. If we do not publish that purl for a product, the component is reported as not tracked. We never fall back to its name, because the name is exactly the guess the purl was invented to replace.

Facing an end-of-life deadline?
Tell us which product and we’ll reply with vetted extended-support options and pricing guidance — free, no obligation. Vendors don’t pay for placement.

Free · No obligation · Independent · dates verified against vendor sources · Not urgent? Follow the EOL radar or see the 2026 EOL calendar →

What the map covers

We publish the join key as purl-map.json: 256 tracked products carrying 1034 package URLs, harvested from endoflife.date's per-product identifier set, from the package-registry ids our own freshness checks read for custom-tracked products, and from a small override file where each entry states its basis. The harvest refuses an ambiguous identifier — one purl claimed by two products — rather than picking a winner, and the site build fails if any key in the map is not a normalised purl or points at a product that was not built. Every product page shows its own purls next to its CPE identifier. Counts below are rewritten from the live map at every build:

Ecosystem (purl type)Package URLsWhat it typically identifies
pkg:rpm169Red Hat, Fedora, SUSE, Amazon Linux packages of runtimes and servers
pkg:github160Source repositories (rarely emitted by SBOM tools; present for completeness)
pkg:docker186Official and vendor container images — the most valuable rows for base-image audits
pkg:maven86Java frameworks, drivers and libraries with published lifecycles
pkg:deb80Debian and Ubuntu packages
pkg:apk55Alpine packages
pkg:scoop, pkg:chocolatey, pkg:winget49, 22, 9Windows package managers
pkg:oci45Registry-neutral container images
pkg:npm42JavaScript frameworks and build tools (React, Angular, Vue, webpack, ESLint, Strapi…)
pkg:generic45Runtimes without a registry home (Node, OpenSSL, PostgreSQL…)
pkg:golang20Go modules with published support lines
pkg:nuget18.NET packages
pkg:composer17PHP frameworks (Laravel, Symfony, Drupal…)
pkg:brew16Homebrew formulae
pkg:pypi7Python frameworks and libraries with published lifecycles (Django, NumPy…)

We track products with a published support commitment. We do not invent dates for the long tail of libraries that have none. Read the table with that in mind: it is not thin because npm has 42 rows; npm has 42 products with a lifecycle to publish.

We track products with a published lifecycle — runtimes, databases, operating systems, frameworks, appliances, the things a vendor or project has committed to supporting until a date. We do not track every library on npm or PyPI, because most of them have no end-of-life policy to track: they are maintained until they are not, and no one publishes the date. An SBOM audit against our data therefore produces three buckets, and the third one is the largest by far: supported, end of life, and not tracked — no verdict. A tool that turns the third bucket into green checkmarks is lying to you.

Four real SBOMs, one afternoon

To show what that looks like at scale we exported the SBOM GitHub generates for any public repository (Insights → Dependency graph → Export SBOM, or the dependency-graph/sbom REST endpoint) for four widely used open-source projects, and ran each through the eol-check GitHub Action in SBOM mode on September 5, 2026. Two things about that export matter for reading the results fairly. It covers every manifest in the repository — the shipped application, but also the linter, the test runner, the documentation site, the example apps — so a finding is about the repository's dependency graph, not necessarily about what the project ships to users. And it records version constraints as written (^18.3.1), which our resolver reports as “version not tracked” rather than silently picking a version to score.

Repository (SPDX export)ComponentsResolved to a tracked productPast end of lifeNot tracked
strapi/strapi5,260815,189
grafana/grafana8,3161048,286
n8n-io/n8n5,9201125,900
keycloak/keycloak1,8511341,838
Total21,347421121,305

(The columns do not sum to the component count because the same product line appears under several components — several copies of [email protected] resolve to one Lodash 4 row — and because a component whose purl we track but whose version we cannot place is counted as not tracked, not as a match.)

Three observations, in order of usefulness.

Resolution is rare and that is correct. Between 0.1% and 0.7% of components in each export resolved to a product with a published lifecycle. The other 99% are libraries with no end-of-life commitment to check against, and the report says so in one line (“5,189 SBOM components not tracked by endoflife.ai — no verdict, never a guess”) instead of 5,189 green rows. Anyone selling you an SBOM tool that returns a lifecycle verdict for every npm package has invented the data.

The end-of-life findings cluster in tooling, and they are real. ESLint 8 (end of life October 5, 2024) appears in two of the four exports and ESLint 9 (August 6, 2026) in three; Grunt 1.5 (January 28, 2023) and jQuery 3 (January 17, 2026) in Grafana's; Bootstrap 3 (July 24, 2019), jQuery 3 and MySQL Connector/J 9 (July 24, 2026) in Keycloak's; the fourth Grafana line, the Gorilla web toolkit, carries an end-of-life flag with no date in the upstream dataset — the project declared the line dead without a calendar date, which our data records as end of life, date not published, and which is still end of life. Most of these are build-time or vendored front-end dependencies rather than the server the project ships, and each project may have retired or isolated them since the export — the point is not that these projects are careless. The point is that a purl-based join found every one of them in seconds across 21,347 components, with a date and a link to the lifecycle page for each, and did not invent a single finding it could not stand behind.

The version format of the export decides what you can score. Keycloak's front-end manifests pin ranges, so the React component's caret range came back as “version not tracked” even though we track React 18. If your SBOM has carets in it, generate it from the artifact, not the manifest.

Manifest SBOM: pkg:npm/react@^18.3.1 → a range, no version to place, no score.
Artifact SBOM (Syft or Trivy on the built image, cdxgen with the lockfile): pkg:npm/[email protected] → React 18, scored.

Three ways to run the audit

The GitHub Action (no key, no rate limit)

The eol-check Action gained an sbom input in version 1.1. Point it at one or more CycloneDX or SPDX JSON files; it downloads our scanner database once (the same file the Stack Scanner uses, rebuilt nightly) and resolves every component locally, so a 3,000-component SBOM makes zero API calls and costs the same as a 3-component one. Past-end-of-life lines fail the job with a file-anchored annotation; the job summary carries the table you saw above.

- uses: anchore/sbom-action@v0        # or Trivy, cdxgen, your build's own SBOM step
  with:
    format: cyclonedx-json
    output-file: sbom.cdx.json
- uses: endoflife-ai/eol-check@v1
  with:
    sbom: sbom.cdx.json
    fail-on: eol                       # or approaching / never
    warn-days: 90

The API (POST /v1/sbom)

Send the SBOM document as the request body; the response resolves and counts every component and returns the same score objects /v1/batch returns, with matched_by telling you whether each match came from a purl or a name. Resolution and counts always cover the whole document; the number of components scored per call is the tier's batch size (five without a key, more with a free or paid key), so the API is the right tool for CI hooks and dashboards and the Action is the right tool for large documents.

curl -s -X POST https://api.endoflife.ai/v1/sbom \
  -H "content-type: application/json" \
  --data-binary @sbom.cdx.json

Our nine-component sample document returned, on the day of writing: components 9, matched 8 (purl 7, name 1), past_eol 2, kev_exposed 1, and one component listed under unmatched with the reason purl not tracked — a left-pad, which has no lifecycle to check. Individual purls also work as batch items ({"products":[{"purl":"pkg:docker/library/postgres@12"}]}), and every score object the API returns now carries the product's purl list next to its cpe. Full request and response shapes are in the API reference.

The Stack Scanner (paste, in the browser)

The Stack Scanner accepts a pasted or uploaded CycloneDX or SPDX document alongside its usual lockfile formats, resolves purl-first against the same map, and never sends the document anywhere — the database comes to the browser, not the other way round. It is the right tool for a one-off look at an SBOM a supplier just sent you. AI agents get the same capability as the check_sbom tool on our MCP server, with the purl map exposed as a resource.

Reading the results

Making it a gate

  1. Generate from the artifact, not the manifest. Syft or Trivy against the built image gives resolved versions and includes the base image and runtime. Commit the SBOM alongside the release.
  2. Run the check on every pull request and weekly on a schedule. End-of-life dates pass whether or not anyone pushes code; a Monday cron catches the ones that passed over the weekend.
  3. Fail on end of life, warn on ninety days. The Action's defaults. Raise warn-days to 180 for anything with a procurement cycle in front of it.
  4. Track the not-tracked count. If it rises sharply between two runs, the SBOM generator changed what it emits, not the codebase.
  5. Publish your purls. If you maintain a product with a real support policy and a page on this site, its purls are already published; if they are missing or wrong, tell us and the fix ships in the next nightly map.

The full lifecycle dataset behind all of this — every product, every version, every date, verified against vendor sources — is on the product index; the API reference documents the SBOM endpoint, batch purl items and the purl field on every score object.

Frequently Asked Questions

What is an SBOM end-of-life audit?

An SBOM end-of-life audit joins every component in a software bill of materials to published lifecycle data to find the ones that no longer receive security fixes. It complements vulnerability scanning, which finds flaws already disclosed; an end-of-life component will receive no fix for the next flaw disclosed. The reliable join key is the component's package URL (purl), not its name.

Why should SBOM components be matched by package URL instead of by name?

Because a package URL is unambiguous and a name is not. The purl encodes the ecosystem, namespace, package and version in one normalised string, so pkg:npm/%40nestjs/core resolves to the NestJS framework while a component simply named core does not. A name match can hand a component another product's lifecycle dates, which produces a confident, wrong answer. endoflife.ai resolves a component with a purl only by that purl and reports it as not tracked otherwise.

How much of a typical SBOM can be checked for end of life?

A small fraction: between 0.1% and 0.7% of components in the four real GitHub-exported SBOMs we ran (21,347 components) resolved to a product with a published lifecycle, and 11 of those lines were past end of life. The rest are libraries with no end-of-life policy to check against; an honest tool reports them as not tracked rather than as supported.

Which SBOM formats does endoflife.ai accept?

CycloneDX JSON (components, including nested components and the metadata component) and SPDX JSON (packages, with the purl read from externalRefs). All three surfaces accept both: the POST /v1/sbom API endpoint, the eol-check GitHub Action's sbom input, and the Stack Scanner's paste or upload box.

Does the SBOM check send my SBOM to endoflife.ai?

Only if you use the API. The GitHub Action and the Stack Scanner download endoflife.ai's scanner database and resolve every component locally, so the SBOM itself never leaves your runner or your browser, and there are no per-component API calls or rate limits.

Related Resources

The Monthly EOL Digest™

Once a month — critical EOL dates, CVE blind spots, and lifecycle changes worth knowing.

© 2026 endoflife.ai · How we verify our dates · API · About · Data from endoflife.date (MIT)