Safeguard
Vulnerability Management•

A Clean Scan Can Also Mean the Vulnerability Data Hasn't Caught Up

A dependency scan that reports nothing has one of two meanings. Either the scanner checked your packages and the vulnerability data found nothing wrong, or the data didn't yet know about something that is wrong. Both produce the same green build.

Safeguard Security Team
Security Research
9 min read

A dependency scan that reports nothing has one of two meanings. Either the scanner checked your packages and the vulnerability data found nothing wrong, or the data didn't yet know about something that is wrong. Both produce the same green build. This post argues that the second case is common enough to plan for, and shows how to tell the difference for any CVE in about a second.

It is for whoever owns the dependency gate in CI, and it applies to every scanner, ours included, because every scanner reads from the same small set of advisory databases.

The short version

Package scanners don't match CVEs. They match advisories that map a CVE to a package name and a version range. Until someone writes that mapping, a published CVE is invisible to pip-audit, npm audit, osv-scanner and most commercial SCA tools, however severe it is. We've written before about scans that come back clean because the scanner never looked. This is the other failure: the scanner looked, and the data had nothing to show it.

This week produced three clean examples of how that happens.

Gap one: the advisory exists but isn't mapped to a package

On 13 October, VulnCheck published CVE-2026-49950, a critical (CVSS 9.8) unauthenticated remote code execution bug in xoscar, the Python actor framework under the Xinference model server. The CVE record names the product and the affected range, 0.1.0 through 0.11.2. GitHub created an advisory for it the same day.

That advisory is still unreviewed. Unreviewed GitHub advisories have no ecosystem or package attached, so nothing that consumes GitHub's data can match it to a requirements.txt. OSV, which pip-audit and osv-scanner read, has no entry for it either. We pinned xoscar==0.11.2, the latest release and still vulnerable, and ran pip-audit. It reported no known vulnerabilities.

This is normal process, not a bug. GitHub reviews advisories before mapping them, because a wrong mapping produces false positives across every repository that uses the package. But review takes time, and in 2026 it has taken much longer than it used to. In our analysis of OSV's exports, covering 3,963 advisories GitHub reviewed this year for CVEs first published elsewhere, the median wait from CVE publication to review was 3.3 days, against 0.5 days for 2025. CVEs published in June 2026 waited a median of 38.5 days, and 58% waited more than a month. The queue had cleared by September. For that stretch, a clean scan meant very little for any CVE less than a month old.

Gap two: nobody assesses software that is out of support

On the same day, Microsoft fixed 46 CVEs in .NET. Take CVE-2026-96225, an elevation of privilege bug in the runtime. Its CVE record lists the affected ranges for .NET 8, 9, 10 and 11. Its GitHub advisory maps eight runtime packages, with a fixed version for each supported line.

.NET 6 and .NET 7 don't appear at all. They reached end of support on 12 November 2024 and 14 May 2024 respectively, and Microsoft's advisories list only supported versions. The advisory doesn't say they are unaffected. It says nothing, and a range matcher reads nothing as safe.

So a service still pinned to .NET 6 gets quieter in a scanner with every Patch Tuesday that passes, while it actually gets less safe. The same thing will happen to .NET 8 and 9 after their support ends on 10 November 2026. It applies to any runtime, framework or library line that has reached end of life: the advisories stop mentioning it exactly when it stops getting fixes.

Gap three: the affected range in the record is wrong

The CVE record is a claim made by whoever filed it, and it can be narrower than the truth. OpenVPN's CVE-2026-84790, published on 13 October, lets a certificate with a hidden NUL byte in its name pass as a different user. The CVE record lists affected versions as 2.7_alpha1 through 2.7.7. OpenVPN's own announcement says 2.3.0 through 2.7.7. We compared the source code and found the vulnerable code, without the fix, in 2.6.23, the latest 2.6 release.

Any tool that trusts the record's range will tell a 2.6 user they are fine. OpenVPN is usually installed from distribution packages, so the scanners at risk are the ones that read distribution security trackers and CPE matches, not npm or PyPI ones. But the lesson travels: an affected range is data entered by a person under time pressure, and data like that gets corrected later, if at all.

Check whether a CVE is visible to your scanner

When an important CVE lands, the useful question isn't "does my scanner flag it?" It's "can my scanner flag it yet?" This script answers that for any CVE ID. It reads the CVE record, then asks GitHub's advisory database and OSV whether the CVE has been mapped to any package. It needs nothing beyond the Python standard library.

#!/usr/bin/env python3
"""Is this CVE visible to a package scanner yet? For each CVE ID, compare the
CVE record with what GitHub's advisory database and OSV (the data behind
pip-audit, osv-scanner and many SCA tools) say about it.
Usage: python3 cve_visibility.py CVE-2026-49950 [CVE-...]"""
import json, sys, urllib.request, urllib.error

def get(url):
    req = urllib.request.Request(url, headers={"User-Agent": "cve-visibility"})
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            return json.load(r)
    except urllib.error.HTTPError as e:
        if e.code == 404:
            return None
        raise

for cve in sys.argv[1:]:
    rec = get(f"https://cveawg.mitre.org/api/cve/{cve}")
    print(f"\n{cve}")
    if not rec:
        print("  CVE record: not found"); continue
    print(f"  CVE record: published {rec['cveMetadata']['datePublished'][:10]}")
    for a in rec["containers"]["cna"].get("affected", []):
        rng = [f"{v.get('version')} to " + (f"<={v['lessThanOrEqual']}" if v.get("lessThanOrEqual")
               else f"<{v['lessThan']}" if v.get("lessThan") else "?")
               for v in a.get("versions", []) if v.get("status") == "affected"]
        print(f"    affected per record: {a.get('product')} {', '.join(rng)}")

    ghsa = get(f"https://api.github.com/advisories?cve_id={cve}") or []
    if not ghsa:
        print("  GitHub: no advisory")
    for adv in ghsa:
        pkgs = sorted({f"{v['package']['ecosystem']}:{v['package']['name']}"
                       for v in adv.get("vulnerabilities") or [] if v.get("package")})
        print(f"  GitHub: {adv['ghsa_id']} {adv['type']}, "
              f"{len(pkgs)} package(s) mapped {pkgs[:3]}{' ...' if len(pkgs) > 3 else ''}")

    # OSV files GitHub-sourced entries under the GHSA ID, with the CVE as an alias
    osv_pkgs = set()
    for oid in [cve] + [adv["ghsa_id"] for adv in ghsa]:
        for a in (get(f"https://api.osv.dev/v1/vulns/{oid}") or {}).get("affected", []):
            if a.get("package", {}).get("ecosystem"):
                osv_pkgs.add(f"{a['package']['ecosystem']}:{a['package']['name']}")
    osv_pkgs = sorted(osv_pkgs)
    print(f"  OSV: {len(osv_pkgs)} package(s) {osv_pkgs[:3]}{' ...' if len(osv_pkgs) > 3 else ''}")
    verdict = "visible to package scanners" if osv_pkgs or any(
        a.get("vulnerabilities") for a in ghsa) else "NOT visible to package scanners yet"
    print(f"  => {verdict}")

Here is the output for the Xoscar and .NET CVEs on 14 October 2026:

CVE-2026-49950
  CVE record: published 2026-10-13
    affected per record: xoscar 0.1.0 to <=0.11.2
  GitHub: GHSA-5rfg-55mw-4h28 unreviewed, 0 package(s) mapped []
  OSV: 0 package(s) []
  => NOT visible to package scanners yet

CVE-2026-96225
  CVE record: published 2026-10-13
    affected per record: .NET 10.0 10.0.0 to <10.0.13
    affected per record: .NET 11.0 11.0.0 to <11.0.0-rc.2
    affected per record: .NET 8.0 8.0.0 to <8.0.32
    affected per record: .NET 9.0 9.0.0 to <9.0.21
    affected per record: Microsoft Visual Studio 2022 version 17.14 17.14.0 to <17.14.42
    affected per record: Microsoft Visual Studio 2026 version 18.10 18.10.0 to <18.10.4
  GitHub: GHSA-w6wh-p4g7-mpj2 reviewed, 8 package(s) mapped ['nuget:Microsoft.NETCore.App.Runtime.linux-arm', 'nuget:Microsoft.NETCore.App.Runtime.linux-arm64', 'nuget:Microsoft.NETCore.App.Runtime.linux-musl-arm'] ...
  OSV: 8 package(s) ['NuGet:Microsoft.NETCore.App.Runtime.linux-arm', 'NuGet:Microsoft.NETCore.App.Runtime.linux-arm64', 'NuGet:Microsoft.NETCore.App.Runtime.linux-musl-arm'] ...
  => visible to package scanners

The .NET result also shows gap two on the screen: the record covers 8 through 11 and stops there. Without a GitHub token the API allows 60 requests an hour, which is plenty for checking the few CVEs that matter on a given day.

What to change in the gate

None of this means throwing out the scanner. It means not treating its silence as the whole answer.

  1. Make end of life a finding in its own right. A runtime or framework past its support date should fail the gate, or at least raise a ticket, whatever the CVE count says. That turns gap two from invisible into the loudest signal in the report.
  2. Check visibility for the CVEs you care about. For anything critical in your stack, run a check like the one above on the day it's published. If the answer is "not visible yet", track it by hand until it is.
  3. Watch the advisories that aren't mapped yet. GitHub publishes unreviewed advisories through the same API with type=unreviewed. Matching their references against the GitHub repositories of your direct dependencies catches cases like Xoscar days or weeks before the mapping lands.
  4. Read the vendor's announcement for anything you're exposed to. When the vendor's text and the CVE record disagree on affected versions, believe the wider range until someone shows otherwise.

The fair counter-argument

Most of the time the data is fine. Outside this year's backlog, review was fast: in 2025 about two thirds of these advisories were reviewed within a day, and by September and October 2026 the median was back to about a day. Chasing every unreviewed advisory would bury a team in noise, much of it about products that aren't packages at all. Curation exists because unmapped data is less precise, and a gate that blocks on imprecise data gets switched off.

So the point isn't that clean scans are worthless. A clean scan answers a narrower question than people assume: "does any mapped advisory match these versions today?" For known, well-maintained dependencies on supported versions, that is close enough to "are we safe?" For something critical that was disclosed this week, or a runtime that left support last year, it isn't. The fix is to know which of those you're looking at, and to watch how the databases themselves keep up rather than assuming they do.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.