Audit an SBOM for End-of-Life Software
Four real SBOMs, 21,347 components, and why name matching is the wrong tool.
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.
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 name is not the product. The NestJS framework's core package is named
corein its own manifest; its purl ispkg:npm/%40nestjs/core. Several hundred other packages are also namedcore, and none of them share that purl. - The same word means different things per ecosystem. The Docker image
nodeis the Node.js runtime; the npm packagenodeis a wrapper that installs it; a Go module whose path ends in/nodeis unrelated to either. - Casing and separators drift. PyPI treats
Django,django,python_dateutilandpython-dateutilas the same names; a string comparison does not. - A near-miss is worse than a miss. Matching
mysql-connector-jtomysqlhands the connector the database's lifecycle dates. The result looks authoritative and is wrong.
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.
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 URLs | What it typically identifies |
|---|---|---|
pkg:rpm | 169 | Red Hat, Fedora, SUSE, Amazon Linux packages of runtimes and servers |
pkg:github | 160 | Source repositories (rarely emitted by SBOM tools; present for completeness) |
pkg:docker | 186 | Official and vendor container images — the most valuable rows for base-image audits |
pkg:maven | 86 | Java frameworks, drivers and libraries with published lifecycles |
pkg:deb | 80 | Debian and Ubuntu packages |
pkg:apk | 55 | Alpine packages |
pkg:scoop, pkg:chocolatey, pkg:winget | 49, 22, 9 | Windows package managers |
pkg:oci | 45 | Registry-neutral container images |
pkg:npm | 42 | JavaScript frameworks and build tools (React, Angular, Vue, webpack, ESLint, Strapi…) |
pkg:generic | 45 | Runtimes without a registry home (Node, OpenSSL, PostgreSQL…) |
pkg:golang | 20 | Go modules with published support lines |
pkg:nuget | 18 | .NET packages |
pkg:composer | 17 | PHP frameworks (Laravel, Symfony, Drupal…) |
pkg:brew | 16 | Homebrew formulae |
pkg:pypi | 7 | Python 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) | Components | Resolved to a tracked product | Past end of life | Not tracked |
|---|---|---|---|---|
| strapi/strapi | 5,260 | 8 | 1 | 5,189 |
| grafana/grafana | 8,316 | 10 | 4 | 8,286 |
| n8n-io/n8n | 5,920 | 11 | 2 | 5,900 |
| keycloak/keycloak | 1,851 | 13 | 4 | 1,838 |
| Total | 21,347 | 42 | 11 | 21,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.
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
- Versions resolve to the release line, never the patch.
[email protected]is scored as PostgreSQL 14;[email protected]as webpack 5. The match is exact cycle, then major.minor, then major — never a prefix (2.28must not match2.3) and never a fallback to the newest line. - “Not tracked” is not “safe”. It means no published lifecycle exists for us to check. Treat it as unknown in your risk model, which is what it is.
- “End of life, no date published” means the project declared the line dead without a calendar date. It is still dead.
- Cross-reference the exploited set. A past-end-of-life line that also appears in CISA's Known Exploited Vulnerabilities catalog is the first thing to fix; the API's
kev_exposedsummary and the Exploited & Unpatchable feed are built for that join. - Runtimes and base images are the high-value rows. In practice the SBOM lines that matter most for lifecycle are the ones your CVE scanner cares least about: the
pkg:dockerbase image, thepkg:genericruntime, the database driver. Generate the SBOM from the built artifact so they are in it.
Making it a gate
- 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.
- 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.
- Fail on end of life, warn on ninety days. The Action's defaults. Raise
warn-daysto 180 for anything with a procurement cycle in front of it. - Track the not-tracked count. If it rises sharply between two runs, the SBOM generator changed what it emits, not the codebase.
- 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
- API reference: POST /v1/sbom and purl batch items
- Stack Scanner: paste a dependency file or SBOM, get a lifecycle report
- Exploited & Unpatchable: end-of-life software in CISA's exploited catalog
- How to Build a Software EOL Management Program (That Survives an Audit)
- End of support vs end of life: the difference that decides your risk