Safeguard
Research•

How Long Until Your Scanner Knows? GitHub Advisory Review Lag Hit a 38-Day Median in June 2026

A CVE being public and your scanner knowing about it are two different events. For open-source packages, the gap between them is mostly the time it takes GitHub to review the advisory and map it to a package name and version range. In 2025 that usually took about half a day. In June 2026 the median was 38 days.

Safeguard Security Team
Security Research
11 min read

A CVE being public and your scanner knowing about it are two different events. For open-source packages, the gap between them is mostly the time it takes GitHub to review the advisory and map it to a package name and version range. In 2025 that usually took about half a day. In June 2026 the median was 38 days.

This post measures that gap across eight package ecosystems, using GitHub's own review timestamps. It is for anyone who relies on Dependabot, pip-audit, npm audit, OSV-Scanner or a commercial SCA tool fed by the same data. The method and script are included, along with a small tool that catches advisories still waiting for review.

The short answer

For CVEs published outside GitHub in 2026, the median time until GitHub reviewed the advisory and mapped it to a package was 3.3 days, against 0.5 days in 2025. It peaked at 38.5 days for CVEs published in June 2026, and 58% of that month's CVEs waited more than 30 days. The backlog cleared in September, when the median returned to about a day. During the gap, tools that read the GitHub Advisory Database or OSV generally can't match the CVE to your dependencies, so a clean scan means less than it appears to.

Why review is the step that matters

The GitHub Advisory Database has two kinds of record. Unreviewed advisories are imported automatically from the CVE feed. They describe the bug but name no package ecosystem, package or version range. Reviewed advisories have been checked by GitHub's curation team and mapped to packages such as pypi/xoscar or npm/express, with affected and fixed versions.

Only reviewed advisories are exported to OSV, and only reviewed advisories drive Dependabot alerts. Most open-source scanners match on package name and version. So for them, a CVE doesn't exist until review is done.

We saw this first-hand this week. CVE-2026-49950, a critical unpatched bug in the Python package xoscar, was published on 13 October as an unreviewed advisory. pip-audit reported the vulnerable version as clean. That raised the question of how long the gap usually lasts.

How we measured it

Every GitHub-reviewed advisory in OSV carries two timestamps in its database_specific block: nvd_published_at (when the CVE appeared in NVD) and github_reviewed_at (when GitHub finished reviewing it). We downloaded the OSV exports for npm, PyPI, Go, Maven, Packagist, RubyGems, crates.io and NuGet on 14 October 2026 (exports last modified between 9 and 13 October). Then we:

  • kept reviewed, non-withdrawn GHSA records, deduplicated by ID;
  • kept records whose CVE was published in NVD in the year being measured;
  • split them into two groups. CVE-first advisories were created in GitHub after the CVE was already in NVD, meaning another organisation published the CVE and GitHub imported it. GHSA-first advisories were published by the maintainer through GitHub, which is also a CVE Numbering Authority. Those are reviewed before or as the CVE goes public, so they have no gap to measure.

Lag is github_reviewed_at minus nvd_published_at, for the CVE-first group. To check that NVD's timestamp is a fair proxy for "public", we compared it against the CVE record's datePublished on cve.org for a random sample of 38 CVE-first advisories from May to July. The median difference was about an hour, and the largest was 17 hours.

#!/usr/bin/env python3
"""How long after a CVE goes public does it become usable by package scanners?
Measures github_reviewed_at minus nvd_published_at for GitHub-reviewed advisories
in OSV exports. Download first, e.g.:
  for e in npm PyPI Go Maven Packagist RubyGems crates.io NuGet; do
    mkdir -p osv/$e && curl -so $e.zip https://osv-vulnerabilities.storage.googleapis.com/$e/all.zip \
      && unzip -qo $e.zip 'GHSA-*' -d osv/$e && rm $e.zip; done
Usage: python3 advisory_lag.py osv [year]"""
import collections, json, pathlib, statistics, sys
from datetime import datetime

def ts(s): return datetime.fromisoformat(s.replace("Z", "+00:00"))
year = sys.argv[2] if len(sys.argv) > 2 else "2026"
seen, rows = set(), []
for path in pathlib.Path(sys.argv[1]).glob("*/GHSA-*.json"):
    adv = json.loads(path.read_text())
    ds = adv.get("database_specific", {})
    if adv["id"] in seen or adv.get("withdrawn") or not ds.get("github_reviewed"):
        continue
    seen.add(adv["id"])
    nvd, rev = ds.get("nvd_published_at"), ds.get("github_reviewed_at")
    if not (nvd and rev and nvd.startswith(year)):
        continue
    days = (ts(rev) - ts(nvd)).total_seconds() / 86400
    imported = ts(adv["published"]) > ts(nvd)        # GHSA created after the CVE hit NVD
    rows.append((path.parent.name, nvd[:7], days, imported, ds.get("severity")))

def summary(label, ds):
    ds = sorted(ds); n = len(ds)
    if not n: return
    pct = lambda f: 100 * sum(f(d) for d in ds) / n
    print(f"{label:<22}{n:>6}  <=1d {pct(lambda d: d <= 1):5.1f}%  >7d {pct(lambda d: d > 7):5.1f}%"
          f"  >30d {pct(lambda d: d > 30):5.1f}%  >90d {pct(lambda d: d > 90):5.1f}%"
          f"  median {statistics.median(ds):6.1f}d  p90 {ds[int(0.9 * (n - 1))]:6.1f}d")

summary("CVE first (imported)", [r[2] for r in rows if r[3]])
print()
for eco in sorted({r[0] for r in rows}):
    summary(eco, [r[2] for r in rows if r[0] == eco and r[3]])
print()
for sev in ["CRITICAL", "HIGH", "MODERATE", "LOW"]:
    summary(sev, [r[2] for r in rows if r[4] == sev and r[3]])
print()
for m in sorted({r[1] for r in rows}):
    summary(m, [r[2] for r in rows if r[1] == m and r[3]])

2025 versus 2026

CVE-first advisoriesCountReviewed within 1 dayWaited more than 30 daysMedian90th percentile
20252,29467.7%4.1%0.5 days3.7 days
2026 (to 13 Oct)3,96331.0%20.3%3.3 days50.5 days

In 2025, nine in ten imported CVEs were mapped to a package within four days. In 2026, one in five waited more than a month.

The summer backlog, month by month

CVE published (2026)CVE-first advisoriesMedian lagWaited more than 30 days
January2580.8 days2.3%
February3380.6 days3.8%
March4601.0 days1.5%
April5751.1 days1.4%
May51515.1 days37.7%
June60638.5 days57.9%
July44918.0 days42.1%
August21115.0 days15.6%
September3641.1 days0.8%
October (1 to 13)1871.0 days0.0%

Severity didn't buy a faster lane. For critical CVEs, the median wait was 11.2 days in May, 31.1 in June, 39.5 in July and 15.0 in August.

The data points to volume rather than a slowdown in effort. GitHub completed between about 200 and 480 reviews a month across these ecosystems in 2025. From March to July 2026 it completed between 1,350 and 1,545 a month, roughly three to four times as many, and the queue still grew. The data can't say what caused the surge in volume, and we won't guess.

Some ecosystems wait longer

Ecosystem (2026, CVE-first)CountMedian lagWaited more than 30 days
Maven6528.0 days37.4%
PyPI9164.2 days23.3%
Go6633.3 days22.9%
NuGet1822.3 days1.6%
Packagist4792.1 days22.8%
npm9301.9 days8.1%
crates.io1011.2 days6.9%
RubyGems400.3 days2.5%

Maven stands out: more than a third of its imported CVEs waited over a month. One plausible reason, which we haven't tested, is that Java CVEs often name a product rather than an artifact, and someone has to work out which Maven coordinates ship it.

What the numbers don't show

Three limits are worth stating plainly.

  • Advisories still waiting aren't counted. We can only measure advisories that have been reviewed. CVEs from September and October that are still in the queue are missing, so those months look better than they will once the stragglers land. The recovery is real, but its size is uncertain.
  • This measures GitHub, not every database. Some commercial vendors map CVEs to packages themselves, on their own schedule. If your tool does, its lag will differ, and asking your vendor for their equivalent number is a reasonable request.
  • GHSA-first advisories have no gap. When maintainers publish through GitHub, the advisory is reviewed before or as the CVE goes public. We excluded these from the lag figures, and many recent ones have no NVD timestamp yet because their CVE was published weeks after the GHSA.

Catch what review hasn't reached yet

Unreviewed advisories are still in the GitHub API, and most link to the project's repository in their references. This script resolves your dependencies to their GitHub repositories through PyPI and npm metadata, then scans recent unreviewed advisories for those repositories.

#!/usr/bin/env python3
"""Find recent *unreviewed* GitHub advisories that point at your dependencies' repos.
Unreviewed advisories have no package mapping, so SCA tools and pip-audit can't see them.
Usage: python3 unreviewed_watch.py [days=14] pypi:xoscar npm:express ...
Set GITHUB_TOKEN to raise the API rate limit (60 requests/hour without one)."""
import json, os, re, sys, urllib.request
from datetime import datetime, timedelta, timezone

args = sys.argv[1:]
days = int(args.pop(0)) if args and args[0].isdigit() else 14
HDR = {"User-Agent": "unreviewed-watch", "Accept": "application/vnd.github+json"}
if os.environ.get("GITHUB_TOKEN"):
    HDR["Authorization"] = "Bearer " + os.environ["GITHUB_TOKEN"]

def get(url):
    hdr = HDR if "api.github.com" in url else {"User-Agent": HDR["User-Agent"]}
    with urllib.request.urlopen(urllib.request.Request(url, headers=hdr), timeout=30) as r:
        return json.load(r), r.headers.get("Link") or ""

def repo_of(spec):
    eco, name = spec.split(":", 1)
    if eco == "pypi":
        info = get(f"https://pypi.org/pypi/{name}/json")[0]["info"]
        urls = list((info.get("project_urls") or {}).values()) + [info.get("home_page") or ""]
    else:  # npm
        meta = get(f"https://registry.npmjs.org/{name}/latest")[0]
        r = meta.get("repository") or {}
        urls = [r.get("url", "") if isinstance(r, dict) else r, meta.get("homepage") or ""]
    for u in urls:
        m = re.search(r"github\.com[/:]([\w.-]+)/([\w.-]+?)(?:\.git)?(?:[/#?]|$)", u or "")
        if m:
            return f"github.com/{m[1]}/{m[2]}".lower()

repos = {}
for spec in args:
    repo = repo_of(spec)
    print(f"{spec:<28} -> {repo or 'no GitHub repo found'}")
    if repo:
        repos[repo] = spec

since = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
url = f"https://api.github.com/advisories?type=unreviewed&published=>{since}&per_page=100"
hits = 0
while url:
    page, link = get(url)
    for adv in page:
        text = " ".join([adv.get("source_code_location") or ""] + (adv.get("references") or [])).lower()
        for repo, spec in repos.items():
            if repo + "/" in text + "/" or repo + " " in text + " ":
                hits += 1
                print(f"\n{spec}: {adv['ghsa_id']} {adv.get('cve_id')} {adv['severity']} published {adv['published_at'][:10]}")
                print(f"  {adv['summary'][:110]}")
    m = re.search(r'<([^>]+)>; rel="next"', link)
    url = m[1] if m else None
print(f"\n{hits} unreviewed advisory match(es) in the last {days} days")
sys.exit(1 if hits else 0)

Run on 14 October against four packages:

$ python3 unreviewed_watch.py 3 pypi:xoscar pypi:requests npm:express pypi:xinference
pypi:xoscar                  -> github.com/xorbitsai/xoscar
pypi:requests                -> github.com/psf/requests
npm:express                  -> github.com/expressjs/express
pypi:xinference              -> github.com/xorbitsai/inference

pypi:xoscar: GHSA-5rfg-55mw-4h28 CVE-2026-49950 critical published 2026-10-13

1 unreviewed advisory match(es) in the last 3 days

It found the Xoscar advisory that pip-audit missed. Two practical notes. Unreviewed volume is high: three days of it took 28 API requests in our test, so set GITHUB_TOKEN for anything longer. And the match depends on the advisory referencing the repository, which most CVE records for open-source code do but not all. This is a supplement to your scanner for your most important dependencies, not a replacement.

What to do with this

If you consume open source: treat the first weeks after a CVE as a blind spot for package-matching tools, and cover it for the dependencies that matter most. That means watching unreviewed advisories as above, or following the projects' own security advisories. When a high-profile CVE lands in something you run, check by hand rather than waiting for the alert.

If you maintain a package: publish your advisory through GitHub's repository security advisories, even if a different organisation issues the CVE. Advisories that start on GitHub skip the import queue entirely. In our data, they were reviewed before or as the CVE went public. That is the single biggest thing a maintainer can do to get users' scanners warning them on day one.

The fair caveat

A review step that adds days is not a flaw in itself. The reason scanners trust GitHub's data is that a person checked the package name and version ranges. Unreviewed automatic mapping would be faster and noisier, and much of the industry's alert fatigue comes from exactly that kind of noise. The problem in 2026 wasn't that review exists. It's that review capacity didn't keep up with volume for four months, and almost nobody downstream could see it. The useful habit is to know where your tool's data comes from and how stale it can get. For more on the NVD side of the same problem, see our post on NVD enrichment delays.

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.