A growing share of this year's security advisories are about vulnerabilities that were already supposed to be fixed. We counted them. Among GitHub-reviewed advisories, the share that describe an incomplete fix or a patch bypass held at roughly 0.4% a year from 2023 to 2025. In 2026 so far it is 2.31%: 250 advisories, against 13 in all of 2025.
This post is for engineers who patch dependencies and maintainers who ship fixes. It covers what we measured, how, which bug classes keep coming back, and what to change in how you write and verify a security fix. The method and a script to reproduce the numbers are at the end.
The short answer
An incomplete-fix advisory is a new advisory for a vulnerability that a previous patch was meant to close, but only closed for some inputs. In 2026, 250 of 10,806 GitHub-reviewed advisories (2.31%) across eight package ecosystems describe one, compared with 0.34% to 0.40% in each of 2023, 2024 and 2025. They cluster in bug classes usually fixed with string or denylist checks: server-side request forgery, path traversal and authorization. Where we could trace the original, the median gap between the first advisory and the follow-up was 73.5 days.
What we measured
| Year | Reviewed advisories | Describe an incomplete fix | Share |
|---|---|---|---|
| 2021 | 2,548 | 12 | 0.47% |
| 2023 | 3,237 | 11 | 0.34% |
| 2024 | 3,527 | 14 | 0.40% |
| 2025 | 3,542 | 13 | 0.37% |
| 2026 (to 13 Oct) | 10,806 | 250 | 2.31% |
Two things are happening at once. The total number of reviewed advisories roughly tripled between 2025 and 2026. The incomplete-fix count grew much faster than that, nearly twentyfold, so the share rose about six times. This is not just more advisories carrying the same proportion of repeats.
We left 2022 out of the comparison on purpose. That year GitHub reviewed a large backlog of older vulnerabilities: 58% of advisories published in 2022 carry a CVE from at least two years earlier, against 1% in 2026. A backfill year is not comparable with years made up of new reports.
The 2026 matches spread across ecosystems: 64 in npm, 64 in PyPI, 47 in Go, 41 in Packagist, 22 in Maven and a handful in NuGet, RubyGems and crates.io. By GitHub's severity rating, 33 are critical, 106 high, 98 moderate and 13 low. These are not mostly cosmetic follow-ups.
Which bugs keep coming back
The CWE tags on the 2026 matches (an advisory can carry more than one) are dominated by a few classes:
| CWE | Class | 2026 incomplete-fix advisories |
|---|---|---|
| CWE-918 | Server-side request forgery | 38 |
| CWE-22 | Path traversal | 28 |
| CWE-863 | Incorrect authorization | 21 |
| CWE-79 | Cross-site scripting | 17 |
| CWE-862 | Missing authorization | 15 |
| CWE-94 | Code injection | 13 |
| CWE-200 | Exposure of sensitive information | 11 |
| CWE-502 | Deserialization of untrusted data | 9 |
The pattern is easy to recognise once you read the advisories. SSRF fixes block localhost and 169.254.169.254, and the follow-up uses a redirect, DNS rebinding, an IPv6-mapped address or a NAT64 prefix. Path traversal fixes reject .., and the follow-up uses a sibling directory or a symlink. Authorization fixes add a check to one route, and the follow-up finds the route next to it. CWE-184, "incomplete list of disallowed inputs", appears seven times on its own, which says the same thing in the taxonomy's own words.
The two teardowns we wrote up this week fit too. fast-jwt's CVE-2026-107722 is the third advisory for the same algorithm confusion bug: the previous fix trimmed whitespace and missed every other leading byte. Nginx UI's GHSA-h246-wpgf-vmq5 is titled, plainly, an incomplete fix of CVE-2026-84315, because one router group was never wrapped in the new check.
How long until the fix gets fixed
For 201 of the 250 advisories, the text names an earlier CVE or GHSA that we could find in the same dataset. For those, the median time from the original advisory's publication to the follow-up was 73.5 days. A quarter arrived within 30 days, and half within about ten weeks. In 169 cases the original was itself published in 2026, so most of this is the same year's fixes being revisited, not old debt resurfacing.
That timing matters for anyone who treats "upgraded to the patched version" as the end of the story. For these classes, there is a real chance of a second advisory within a quarter.
A worked example: one function, two fixes
CVE-2026-35592 in pyload-ng is a clean illustration. An earlier fix added a correct helper built on os.path.commonpath(). The tar extraction code kept its own check using os.path.commonprefix(), which compares strings character by character, not path components. The difference is easy to see:
# prefix_check.py (python3 prefix_check.py, Python 3.9+)
import os
from pathlib import Path
BASE = "/srv/app/uploads"
def unsafe_inside(target):
target = os.path.abspath(target)
return os.path.commonprefix([BASE, target]) == BASE
def safe_inside(target):
return Path(target).resolve().is_relative_to(Path(BASE).resolve())
for t in ["/srv/app/uploads/a.png",
"/srv/app/uploads/../config/secrets.env",
"/srv/app/uploads_backup/db.sqlite"]:
print(f"{t:42} commonprefix={unsafe_inside(t)!s:5} resolve+is_relative_to={safe_inside(t)}")
Output:
/srv/app/uploads/a.png commonprefix=True resolve+is_relative_to=True
/srv/app/uploads/../config/secrets.env commonprefix=False resolve+is_relative_to=False
/srv/app/uploads_backup/db.sqlite commonprefix=True resolve+is_relative_to=False
Both checks stop the obvious .. case, which is the one a test usually covers. Only the path-aware check stops the sibling directory. The project had the right function. It just wasn't used everywhere the same decision was made.
Why the rise? Some honest hypotheses
We can measure the increase. We cannot prove its cause from this data, so treat these as hypotheses:
- More people check fixes now. Many 2026 advisories read like variant analysis: the reporter took a published fix, read the diff and looked for the inputs it missed. That is a sign of healthy scrutiny, not only of worse patching.
- Faster fix cycles. A fix written and released within a day or two of a report is good for users, but it is more likely to address the exact proof of concept than the whole class.
- Volume pressure on maintainers. With overall advisory volume roughly tripling, the same maintainers are handling far more reports.
The first hypothesis is a fair counter-argument to reading this as bad news. A rising count of incomplete-fix advisories partly means bypasses that once went unreported are now being found and disclosed. Either way, the practical response for defenders is the same.
What to change when you write a security fix
Fix the class, not the proof of concept. Before merging, write down the bug class in one line ("user input reaches a filesystem path", "user input chooses an outbound host") and check every place that class occurs, not just the reported line. The pyload case is exactly a second call site.
Prefer structural checks over denylists. Resolve the path, then check it is inside the base. Resolve the hostname, then check the resulting address against an allowlist, and pin that address for the actual request so DNS cannot change underneath you. Enforce authorization in one middleware layer that every route passes through, rather than per handler.
Turn the report into a family of tests. For every security fix, add the reported input plus its obvious variants: encoded forms, sibling paths, alternate address formats, the neighbouring route. Our post on security regression testing covers how to keep those tests from rotting.
Read the fix diff, not just the version number. When you consume a security release for an SSRF, path traversal or authorization bug, take five minutes to read what changed. If it adds a string to a denylist, plan to check for a follow-up.
Watch for the second advisory. For packages that just had a security release in one of these classes, keep them on a short watch list for the following quarter. The median gap in our data was ten weeks.
Method and reproduction
We used the OSV.dev exports for eight ecosystems (npm, PyPI, Go, Maven, Packagist, RubyGems, crates.io, NuGet), downloaded on 13 October 2026. We kept only records that originate from the GitHub Advisory Database (GHSA IDs) and are marked as GitHub-reviewed, dropped withdrawn records, and deduplicated across ecosystems. The year is the advisory's GHSA publication date. A record counts as an incomplete-fix advisory if its summary or details match the pattern in the script below.
We checked precision by hand. All 50 of a random sample of 2026 matches, and all 27 matches from 2024 and 2025, described a genuine incomplete fix or patch bypass. An early version of the pattern matched "prefix bypass" and "suffix bypass", which we fixed with word boundaries. We did not measure recall: advisories that describe an incomplete fix in other words are missed, so every count here is a lower bound.
#!/usr/bin/env python3
"""Count GitHub-reviewed advisories that describe an incomplete fix, per year.
Input: a directory of unzipped OSV exports, one subfolder per ecosystem, e.g.
for e in npm PyPI Go Maven Packagist RubyGems crates.io NuGet; do
mkdir -p osv/$e && curl -sO https://osv-vulnerabilities.storage.googleapis.com/$e/all.zip \
&& unzip -qo all.zip -d osv/$e && rm all.zip; done
Usage: python3 count_incomplete_fixes.py osv"""
import collections, json, pathlib, re, sys
PATTERN = re.compile(
r"\bincomplete (fix|patch|remediation|mitigation)\b"
r"|\b(fix|patch) (for|of) (CVE-\d{4}-\d+|GHSA-[\w-]+)[^.]{0,60}\b(is|was) incomplete"
r"|\bbypass(es|ing)? (of )?(the )?(previous |prior |original )?(fix|patch) (for|of)\b"
r"|\bpatch bypass\b|\bfix bypass\b", re.I)
seen, totals, hits = set(), collections.Counter(), collections.Counter()
for path in pathlib.Path(sys.argv[1]).glob("*/GHSA-*.json"):
adv = json.loads(path.read_text())
if adv["id"] in seen or adv.get("withdrawn"):
continue
if not adv.get("database_specific", {}).get("github_reviewed"):
continue
seen.add(adv["id"])
year = adv["published"][:4]
totals[year] += 1
if PATTERN.search(adv.get("summary", "") + "\n" + adv.get("details", "")):
hits[year] += 1
for year in sorted(y for y in totals if y >= "2021"):
print(f"{year} {totals[year]:6} {hits[year]:4} {100 * hits[year] / totals[year]:.2f}%")
The npm export is large (about 220 MB compressed, mostly malware records), so allow a few minutes. Running it later than 13 October will give slightly higher 2026 numbers as new advisories land.
The takeaway
A security release is a claim that a class of input can no longer reach a dangerous place. In 2026, that claim has turned out to be partial far more often than in previous years, especially for SSRF, path traversal and authorization. The fix for that is not slower patching. It is fixing at the level of the class, testing the variants before release, and on the consuming side, reading what a fix actually changed.
If you want remediation to arrive as a tested pull request rather than a version bump alone, that is how Safeguard Auto-Fix is designed to work.