Safeguard
Research•

The Fix Ships First: In 2026, Patched Releases Beat the Advisory by a Median of 10 Days

In 97% of 4,259 PyPI and npm advisories published in 2026, the fixed version was on the registry before the advisory went public, by a median of 10 days. Why the advisory is a late signal, this week's examples, and how to watch releases without walking into compromised updates.

Safeguard Security Team
Security Research
10 min read

Most teams start patching when an advisory arrives. By then the fix has usually been public for more than a week. We measured it across this year's GitHub-reviewed advisories for Python and JavaScript packages. In 97% of cases the fixed version was on PyPI or npm before the advisory went public, and the median head start was 10 days. During that window, the fix is visible to anyone reading release notes or diffs, and invisible to anyone waiting for their scanner.

This post argues that release streams, not advisory feeds, should be the first signal for your critical dependencies. It's for teams that own dependency updates and patch SLAs. It includes the data and method, this week's examples, a script that watches your dependencies' releases, and the honest counter-argument, because upgrading faster has risks too.

The short answer

For 4,259 GitHub-reviewed PyPI and npm advisories that went public in 2026, the earliest fixed version was released a median of 10 days before the advisory. More than half (56.6%) were released over a week earlier, and 28.8% over a month earlier. Severity barely changed how often the fix came first (94.8% to 98.0% across levels), but it did change the size of the head start: the median was 12.8 days for high-severity advisories and 5.4 for low. If your process starts at the advisory, you start late. That's before counting the days a scanner may need to map the advisory to your packages.

The numbers

For each reviewed advisory, we took the date it went public (the earlier of its GitHub publication and NVD publication) and compared it with the earliest registry release date of any version the advisory lists as fixed:

5020 advisories with fixed versions; 4259 with a release date for the fix

all             4259  fix first  97.4%  >1d  82.5%  >7d  56.6%  >30d  28.8%  median   10.0d
PyPI            1937  fix first  95.7%  >1d  81.6%  >7d  56.8%  >30d  28.1%  median    9.9d
npm             2322  fix first  98.9%  >1d  83.2%  >7d  56.5%  >30d  29.3%  median   10.3d
critical         495  fix first  97.8%  >1d  82.8%  >7d  56.8%  >30d  33.5%  median   10.9d
high            1826  fix first  98.0%  >1d  84.8%  >7d  59.7%  >30d  32.4%  median   12.8d
moderate        1650  fix first  97.2%  >1d  80.6%  >7d  54.8%  >30d  24.9%  median    8.5d
low              288  fix first  94.8%  >1d  78.1%  >7d  46.9%  >30d  19.8%  median    5.4d

"Fix first" is the share of advisories whose fixed version was released before the advisory went public. The median gap was 10.0 days overall, 9.9 for PyPI and 10.3 for npm. The 75th percentile was about 35 days and the 90th about 72. Only 96 advisories, about 2%, went public more than a day before any fix was released.

This week's examples

The aggregate matches what we saw in the advisories we researched this week:

ProjectFixed releaseAdvisory or CVE publicHead start
urllib3 2.8.0 (three advisories)15 Sep30 Sepabout 14 days
Collabora CODE app 26.4.30311 SepCVE 14 Oct33 days, exploited throughout
Libreswan 5.4.1 / 5.3.35 Oct (with advisories)CVE records 13 Oct8 days
OpenVPN 2.7.87 Oct13 Oct6 days
Nix 2.35.3 and Lix 2.95.413 Oct13 Octsame day

The Collabora case is the uncomfortable one. Attacks were reported on 10 September, the fix shipped on 11 September under a commit titled "tidy up", and the CVE arrived five weeks later. Anyone who upgraded with the release was protected. Anyone waiting for a CVE wasn't. Nix and Lix show the other model: a coordinated release with advisories, versions and package updates on the same day.

Why the fix comes first

Mostly for good reasons. Maintainers fix the bug, release, and then publish the advisory. Sometimes that's deliberate, to give users time to upgrade before the details are public. Sometimes it's just that the CVE paperwork or the advisory review takes longer than the code. Some advisories in the long tail describe bugs fixed long ago and only reported later. Those inflate the 30-day share, which is why the median and the 7-day figure are the fairer summaries.

The effect is the same either way. The fix, and often its diff, is public before the advisory. Anyone who reads diffs, including attackers, can see what changed, and dependency tooling that keys on advisories says nothing until the advisory lands.

Watch releases, not just advisories

For the dependencies that matter most, such as anything on the request path, parsing untrusted input, or holding credentials, add a release signal next to the advisory signal. This script lists recent releases for the packages you name and flags release notes that mention security, CVEs, GHSAs or common bug classes:

#!/usr/bin/env python3
"""List releases from the last N days for the packages you depend on, and flag
release notes that mention security. Fixes usually ship before the advisory, so
this is the earliest signal you can get. Unauthenticated GitHub API: 60 requests/hour.
Usage: python3 release_watch.py [days=14] pypi:urllib3 npm:express ..."""
import json, os, re, sys, urllib.parse, 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
since = datetime.now(timezone.utc) - timedelta(days=days)
SEC = re.compile(r"\b(security|CVE-\d{4}-\d+|GHSA-[\w-]+|vulnerab\w*|advisory|exploit|injection|"
                 r"overflow|traversal|bypass|denial of service|DoS|RCE|SSRF|XSS|sanitiz\w*)\b", re.I)
HDR = {"User-Agent": "release-watch"}
if os.environ.get("GITHUB_TOKEN"):
    HDR["Authorization"] = "Bearer " + os.environ["GITHUB_TOKEN"]

def get(url):
    with urllib.request.urlopen(urllib.request.Request(url, headers=HDR), timeout=30) as r:
        return json.load(r)

def registry(spec):
    eco, name = spec.split(":", 1)
    if eco == "pypi":
        d = get(f"https://pypi.org/pypi/{name}/json")
        rel = {v: min(f["upload_time_iso_8601"] for f in fs) for v, fs in d["releases"].items() if fs}
        urls = list((d["info"].get("project_urls") or {}).values()) + [d["info"].get("home_page") or ""]
    else:
        d = get(f"https://registry.npmjs.org/{urllib.parse.quote(name, safe='@')}")
        rel = {v: t for v, t in d.get("time", {}).items() if v not in ("created", "modified")}
        r = d.get("repository") or {}
        urls = [r.get("url", "") if isinstance(r, dict) else r]
    repo = next((m[1] for u in urls if (m := re.search(r"github\.com[/:]([\w.-]+/[\w.-]+?)(?:\.git)?(?:[/#?]|$)", u or ""))), None)
    return rel, repo

for spec in args:
    rel, repo = registry(spec)
    recent = sorted((t, v) for v, t in rel.items() if datetime.fromisoformat(t.replace("Z", "+00:00")) >= since)
    print(f"\n{spec}  ({len(recent)} release(s) in {days} days; repo: {repo or 'unknown'})")
    notes = {}
    if recent and repo:
        try:
            for r in get(f"https://api.github.com/repos/{repo}/releases?per_page=20"):
                notes[r["tag_name"].lstrip("v")] = r.get("body") or ""
        except Exception as e:
            print(f"  (no GitHub release notes: {e})")
    for t, v in recent:
        hits = sorted({m.group(0).lower() for m in SEC.finditer(notes.get(v, ""))})
        flag = f"  <- notes mention: {', '.join(hits[:6])}" if hits else ""
        print(f"  {t[:10]}  {v}{flag}")

Run on 14 October for a 30-day window:

$ python3 release_watch.py 30 pypi:urllib3 pypi:aiohttp pypi:xoscar npm:express
pypi:urllib3  (1 release(s) in 30 days; repo: urllib3/urllib3)
  2026-09-15  2.8.0  <- notes mention: ghsa-8988-9cw3-xx77, ghsa-gh4c-6fx4-qh6g, ghsa-vxq7-64xx-v4gw, security

pypi:aiohttp  (2 release(s) in 30 days; repo: aio-libs/aiohttp)
  2026-10-05  3.14.4  <- notes mention: cve-2025-69224, overflow, security
  2026-10-11  3.14.5

pypi:xoscar  (4 release(s) in 30 days; repo: xorbitsai/xoscar)
  2026-09-30  0.10.1
  2026-10-01  0.11.0
  2026-10-02  0.11.1
  2026-10-07  0.11.2

npm:express  (1 release(s) in 30 days; repo: expressjs/express)
  2026-10-09  5.3.0  <- notes mention: cve-2026-12590, cve-2026-2391, cve-2026-82417, cve-2026-82562, cve-2026-90711, denial of service

urllib3's 2.8.0 notes named three GHSA IDs on 15 September, two weeks before the advisories went public. Express 5.3.0 listed its CVEs in the release notes. Xoscar, which has an unpatched critical CVE this week, shows four releases and no security mention, which is accurate because there's no fix yet. A watcher can only see what maintainers write down. A fix shipped as "tidy up", like Collabora's, gets past it. That's the argument for upgrading critical dependencies on a regular cadence, not only when something is flagged.

The fair counter-argument

Upgrading the moment a release appears has its own risk: a fresh release is exactly what a hijacked maintainer account or a compromised build publishes. Many teams deliberately wait a few days before taking new versions, using cooldown settings in tools like Renovate or Dependabot, so the community has time to spot malicious releases. That's sensible, and it pulls in the opposite direction from this data.

The two can coexist. Keep a cooldown for routine updates. Fast-track releases whose notes mention security, and releases of the handful of dependencies whose compromise or vulnerability would hurt most. Treat an advisory as confirmation of a decision you've often already been able to make, not the start of the process. A ten-day median gap leaves room to wait two days for malware checks and still finish a week ahead of an advisory-driven process.

Method and limits

We used the OSV exports for PyPI and npm (downloaded 14 October 2026), kept GitHub-reviewed, non-withdrawn advisories that went public in 2026 and list at least one fixed version, and looked up each fixed version's first upload time on PyPI or npm. The script is below. 761 of the 5,020 advisories were excluded because a fixed version couldn't be matched to a registry release, usually because of version-string differences. The comparison uses the earliest fixed release across all affected branches, and "public" means the earlier of the GHSA and NVD dates. A maintainer's own changelog or advisory page may be earlier still.

#!/usr/bin/env python3
"""How long before an advisory goes public is the fixed version already released?
For GitHub-reviewed PyPI and npm advisories in OSV exports, compare the earliest
release date of any fixed version (from the registry) with the date the advisory
went public (earlier of GHSA publication and NVD publication).
Usage: python3 patch_gap.py <osv-export-dir> [year]"""
import json, os, pathlib, statistics, sys, urllib.parse, urllib.request
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime

root, year = pathlib.Path(sys.argv[1]), (sys.argv[2] if len(sys.argv) > 2 else "2026")
CACHE = pathlib.Path(__file__).parent / "cache"
ts = lambda s: datetime.fromisoformat(s.replace("Z", "+00:00"))

def release_times(eco, name):
    """version -> first upload time, from PyPI JSON or the npm registry document."""
    f = CACHE / f"{eco}_{urllib.parse.quote(name, safe='')}.json"
    if f.exists():
        return json.loads(f.read_text())
    url = (f"https://pypi.org/pypi/{name}/json" if eco == "PyPI"
           else f"https://registry.npmjs.org/{urllib.parse.quote(name, safe='@')}")
    try:
        with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "patch-gap"}), timeout=60) as r:
            d = json.load(r)
        if eco == "PyPI":
            out = {v: min(x["upload_time_iso_8601"] for x in fs) for v, fs in d.get("releases", {}).items() if fs}
        else:
            out = {v: t for v, t in d.get("time", {}).items() if v not in ("created", "modified")}
    except Exception:
        out = {}
    f.write_text(json.dumps(out))
    return out

advs = []
for eco in ("PyPI", "npm"):
    for p in (root / eco).glob("GHSA-*.json"):
        a = json.loads(p.read_text()); ds = a.get("database_specific", {})
        if a.get("withdrawn") or not ds.get("github_reviewed"):
            continue
        public = min(x for x in [a.get("published"), ds.get("nvd_published_at")] if x)
        if not public.startswith(year):
            continue
        fixes = {(af["package"]["name"], e["fixed"]) for af in a.get("affected", [])
                 for r in af.get("ranges", []) for e in r.get("events", []) if e.get("fixed")}
        if fixes:
            advs.append((eco, a["id"], public, sorted(fixes), a.get("database_specific", {}).get("severity")))

pkgs = sorted({(eco, n) for eco, _, _, fx, _ in advs for n, _ in fx})
with ThreadPoolExecutor(16) as ex:
    times = dict(zip(pkgs, ex.map(lambda k: release_times(*k), pkgs)))

rows = []
for eco, gid, public, fixes, sev in advs:
    rel = [times[(eco, n)].get(v) for n, v in fixes]
    rel = [r for r in rel if r]
    if rel:
        rows.append((eco, gid, sev, (ts(public) - ts(min(rel))).total_seconds() / 86400))

def summary(label, ds):
    ds = sorted(ds); n = len(ds)
    if n:
        pct = lambda f: 100 * sum(map(f, ds)) / n
        print(f"{label:<14}{n:>6}  fix first {pct(lambda d: d > 0):5.1f}%  >1d {pct(lambda d: d > 1):5.1f}%"
              f"  >7d {pct(lambda d: d > 7):5.1f}%  >30d {pct(lambda d: d > 30):5.1f}%  median {statistics.median(ds):6.1f}d")

print(f"{len(advs)} advisories with fixed versions; {len(rows)} with a release date for the fix\n")
summary("all", [r[3] for r in rows])
for eco in ("PyPI", "npm"):
    summary(eco, [r[3] for r in rows if r[0] == eco])
for sev in ("CRITICAL", "HIGH", "MODERATE", "LOW"):
    summary(sev.lower(), [r[3] for r in rows if r[2] == sev])
json.dump(rows, open(pathlib.Path(__file__).parent / "rows.json", "w"))

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.