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:
| Project | Fixed release | Advisory or CVE public | Head start |
|---|---|---|---|
| urllib3 2.8.0 (three advisories) | 15 Sep | 30 Sep | about 14 days |
| Collabora CODE app 26.4.303 | 11 Sep | CVE 14 Oct | 33 days, exploited throughout |
| Libreswan 5.4.1 / 5.3.3 | 5 Oct (with advisories) | CVE records 13 Oct | 8 days |
| OpenVPN 2.7.8 | 7 Oct | 13 Oct | 6 days |
| Nix 2.35.3 and Lix 2.95.4 | 13 Oct | 13 Oct | same 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"))