Safeguard
Research•

GitHub Withdrew 517 Duplicate Advisories in 2026: Where They Linger and How to Clean Up

GitHub withdrew 517 Go, npm and PyPI advisories in 2026, nearly all duplicates live for two weeks. Where they linger in your findings and a cleanup script.

Safeguard Security Team
Security Research
8 min read

GitHub withdrew 517 advisories for Go, npm and PyPI packages between January and 18 October 2026. In all of 2025 it withdrew 76. Nearly every 2026 withdrawal was a duplicate: a second advisory for a vulnerability that already had one, usually created when a CVE record for the same issue arrived from elsewhere. Each duplicate stayed live for a median of two weeks before it was withdrawn, and while it was live, scanners could report the same bug twice.

This post is for teams that track vulnerability findings in tickets, dashboards or their own databases, and for anyone who ingests advisory data in bulk. It covers what the data shows, how withdrawal works, where duplicates linger after the database has cleaned them up, and a script that finds withdrawn advisories in a findings export and tells you which advisory to track instead.

The short answer

GitHub's advisory database withdrew 517 Go, npm and PyPI advisories in 2026 to 18 October, against 76 in 2025. 500 of them were duplicates, live for a median of 14 days and in 15% of cases more than 90, across every severity including 90 critical. Every duplicate names the advisory it duplicates, so the fix is mechanical: close findings that point at withdrawn IDs and track the named advisory. OSV's query API already hides withdrawn records, but its bulk data dumps keep them with a withdrawn timestamp, and tickets opened during the window stay open until someone closes them.

What the data shows

We counted withdrawn GitHub advisories in OSV's Go, npm and PyPI data dumps on 18 October, by the date each was withdrawn:

$ python3 withdrawn_stats.py osv-dumps
withdrawn per year: 2019 4, 2020 36, 2021 23, 2022 7, 2023 39, 2024 71, 2025 76, 2026 517
2026 (to 18 Oct): 517 withdrawn; duplicates 500; other 17
2026 by ecosystem: {'Go': 85, 'npm': 274, 'PyPI': 158}
2026 by severity: {'CRITICAL': 90, 'MODERATE': 196, 'HIGH': 186, 'LOW': 45}
duplicates: median 14 days live before withdrawal; 34% within a week; 15% after 90+ days
duplicates naming the advisory they duplicate: 500; linking a CVE record: 493
2026 withdrawals by month: 01 13, 02 25, 03 46, 04 45, 05 57, 06 53, 07 42, 08 20, 09 115, 10 101
2026 to 18 October
Advisories withdrawn517 (76 in all of 2025)
Duplicates500
Other reasons17
By ecosystemnpm 274, PyPI 158, Go 85
By severitycritical 90, high 186, moderate 196, low 45
Duplicate's time live before withdrawalmedian 14 days; 34% within a week; 15% over 90 days
Duplicates naming the advisory they duplicate500 of 500
Duplicates linking a CVE record493 of 500

Withdrawals also cluster in time: 115 in September and 101 in the first 18 days of October, against 20 in August. That's consistent with the review backlog we measured separately in GitHub repository advisories, which grew over the summer: when a large batch is reviewed, duplicates created in the meantime would be merged out together.

How a duplicate happens

The pattern in the records is consistent. A project publishes a repository security advisory for a bug. Separately, a CVE record for the same bug is published, sometimes by a different CNA. GitHub's database creates an advisory from the CVE record. When reviewers connect the two, one is withdrawn, and its text says so plainly:

This advisory has been withdrawn because it is a duplicate of GHSA-pqf9-h8g4-8gmh.

The withdrawn record keeps its page "to preserve external references", so links in tickets and reports still resolve. It just stops counting.

The OSV schema has a field for this. Its withdrawn field holds the time the entry should be considered withdrawn, and the schema says any rationale belongs in the summary, which is where the word "Duplicate" comes from.

Where duplicates linger

The database fixes itself. Your copies don't. We checked three places:

  • OSV's query API didn't return withdrawn advisories. For four withdrawn duplicates, querying the affected package and version returned the active advisory and not the duplicate. A scanner that queries live gets the clean answer.
  • OSV's bulk dumps include withdrawn records, with the withdrawn timestamp. A tool that loads the dumps into its own database has to honour that field, or it reports withdrawn advisories forever.
  • Your tickets and dashboards keep whatever was true when the finding was created. A finding opened from a duplicate during its two weeks live stays open, often next to a second ticket for the same bug, until someone notices.

That last one is the common case. Two tickets for one bug means two SLA clocks, double counts in metrics, and sometimes two owners fixing the same thing.

Find withdrawn advisories in your findings

This script reads any export that contains GHSA IDs, whether CSV, JSON or plain text, looks each one up in OSV, and reports which are withdrawn and which advisory replaced them. It uses only the Python standard library:

#!/usr/bin/env python3
"""Find withdrawn advisories in a findings export and point to the advisory that replaced them.

  withdrawn_check.py FINDINGS_FILE [...]    any text, CSV or JSON containing GHSA IDs

Looks each GHSA ID up in OSV. Withdrawn duplicates name the advisory they duplicate in
their details, so the script reports that ID as the one to track. Standard library only.
"""
import json, re, sys, urllib.error, urllib.request

GHSA = re.compile(r"GHSA-[23456789cfghjmpqrvwx]{4}-[23456789cfghjmpqrvwx]{4}-[23456789cfghjmpqrvwx]{4}")

def osv(gid):
    try:
        req = urllib.request.Request(f"https://api.osv.dev/v1/vulns/{gid}", headers={"User-Agent": "withdrawn-check"})
        with urllib.request.urlopen(req, timeout=20) as r:
            return json.load(r)
    except urllib.error.HTTPError as e:
        return None if e.code == 404 else {"error": e.code}

def main(paths):
    ids = sorted({m for p in paths for m in GHSA.findall(open(p, errors="replace").read())})
    stale = 0
    print(f"{len(ids)} GHSA IDs found")
    for gid in ids:
        rec = osv(gid)
        if rec is None:
            print(f"  {gid}  not in OSV (unreviewed or repository-only advisory)")
        elif rec.get("withdrawn"):
            stale += 1
            others = sorted(set(GHSA.findall(rec.get("details", ""))) - {gid})
            target = f"track {others[0]} instead" if others else "no replacement named"
            print(f"  {gid}  WITHDRAWN {rec['withdrawn'][:10]}: {target}")
        else:
            print(f"  {gid}  active")
    print(f"{stale} finding(s) reference withdrawn advisories")
    return 1 if stale else 0

if __name__ == "__main__":
    if len(sys.argv) < 2:
        sys.exit(__doc__)
    sys.exit(main(sys.argv[1:]))

On a small findings export with six real advisory IDs:

$ python3 withdrawn_check.py findings-export.csv
6 GHSA IDs found
  GHSA-236c-vhj4-gfxg  WITHDRAWN 2026-02-17: track GHSA-pjwm-rvh2-c87w instead
  GHSA-3f84-vwv5-r42g  WITHDRAWN 2026-10-01: track GHSA-647f-g98j-qq25 instead
  GHSA-55p4-8j34-jv53  WITHDRAWN 2026-10-09: track GHSA-pqf9-h8g4-8gmh instead
  GHSA-pqf9-h8g4-8gmh  active
  GHSA-v5mq-3xhg-98m9  active
  GHSA-wwv9-g5x4-966q  active
3 finding(s) reference withdrawn advisories
(exit 1)

Three of the six findings point at withdrawn duplicates. Two of the tickets track the same bug under different IDs, which the export alone doesn't show. Closing the stale three with a note pointing at the replacement fixes the count and keeps the history.

Triage tools make this a status change rather than a deletion. Here's Safeguard's interactive walkthrough of moving findings through a triage workflow:

What to do

  1. Run the check on open findings from any tool that keys on GHSA IDs, and close or merge the ones pointing at withdrawn advisories.
  2. Honour withdrawn if you ingest OSV dumps. Filter withdrawn records out at load time, and re-sync regularly, since withdrawals happen weeks after publication.
  3. Key findings on aliases, not one ID. A finding that records the GHSA ID and the CVE ID can be matched when one record replaces another. Our OSV schema explainer covers the aliases and withdrawn fields.
  4. Expect bursts after review catch-ups. September and October show that withdrawals arrive in batches. A weekly check is enough.
  5. Count unique vulnerabilities in metrics, not advisory IDs, so duplicates don't inflate the numbers. Our post on reducing alert fatigue covers the wider problem.

The fair view

Withdrawal is the system working. The database found duplicates, kept the old links resolvable, named the replacement in every case, and the OSV API stopped returning them. Duplicates are a side effect of an open ecosystem where several organisations can publish records for the same bug, and catching them is part of the review work that makes the database worth using.

The cost lands downstream, in the copies. A finding is a snapshot, and nothing updates a snapshot when the source changes. The habit that fixes it is cheap: treat advisory IDs as references that can be redirected, check them against the source on a schedule, and close what's been withdrawn.

Check yourself

Your scanner queries OSV live. Do withdrawn duplicates still show up in its results?

Not in new results: in our test, OSV's query API returned the active advisory and not the withdrawn duplicate. But findings created before the withdrawal stay in your tickets until you close them.

You load OSV's bulk dumps into your own database. What must you do?

Filter out records with a withdrawn timestamp, and re-sync regularly. The dumps keep withdrawn records so links still work, which means a loader that ignores the field will keep reporting them.

A finding points at a withdrawn advisory. How do you know what to track instead?

Read the withdrawn record's details. Every 2026 duplicate in our data names the advisory it duplicates, as in "a duplicate of GHSA-...". The script above extracts that ID for you.

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.