Safeguard
Vulnerability Analysis•

When GitHub and OSV Disagree on Affected Versions: The Range Gap in 2026 Advisories

A GitHub advisory can record 'affected below X' and 'patched in Y' with Y above X. OSV-based tools flag the releases in between, GitHub's range calls them safe. 2026 data across eight ecosystems, three cases that went different ways (vm2, Angular, Hazelcast), and a script that finds the gaps in your lockfiles.

Safeguard Security Team
Security Research
10 min read

Some GitHub advisories carry two answers about which versions are affected. One is the advisory's affected range, which GitHub's advisory pages and API show. The other is the first patched version, which OSV turns into the end of the range. Usually they meet. When they don't, every release in between is affected according to OSV and unaffected according to GitHub's range, and the advisory itself doesn't say which is true.

This post is for teams that compare scanner results, argue with a finding, or wonder why two tools disagree about the same dependency. It covers how the gap arises, how often it affects real released versions in 2026, three cases where it went different ways, and a script that finds dependencies sitting in a gap.

The short answer

A GitHub advisory can record "affected below X" and "patched in Y" with Y above X. OSV-based tools treat everything below Y as affected, while GitHub's range treats everything from X up as safe. In GitHub-reviewed advisories published in 2026, 135 of 11,020 across eight ecosystems had at least one stable release in that gap, 447 releases in all. Neither answer is reliably right: in the cases we checked, sometimes the range was right and sometimes the patched version was. If your version is in a gap, read the project's release notes for that version before you trust either tool.

Where the second answer comes from

GitHub publishes its advisory database in the OSV format. Each affected package gets a range made of events, such as introduced: 0 and fixed: 1.21.0, and OSV-based scanners evaluate those events. GitHub also records its own range string, such as <= 1.20.0, which is what its advisory pages show as affected versions. In the OSV data, that string survives in a side field called last_known_affected_version_range, which scanners don't use.

Here's one affected package from an onnx advisory, as OSV serves it (trimmed):

{
  "package": { "ecosystem": "PyPI", "name": "onnx" },
  "ranges": [{ "type": "ECOSYSTEM", "events": [{ "introduced": "0" }, { "fixed": "1.21.0" }] }],
  "database_specific": { "last_known_affected_version_range": "<= 1.20.0" }
}

onnx 1.20.1 was released on 10 January 2026, before the advisory. OSV says it's affected, because it's below 1.21.0. GitHub's range says it isn't, because it's above 1.20.0. Nothing in the record says whether anyone checked 1.20.1.

The two values usually agree in substance. "Affected up to 1.2.2, patched in 1.2.3" leaves nothing in between. The gap only matters when a real release falls inside it.

How often a real release lands in the gap

We took every GitHub-reviewed advisory published in 2026 from OSV's data dumps on 18 October, found the affected packages where GitHub's range ends below the fixed version, and listed the stable releases in between. For Maven, NuGet, Packagist, PyPI and RubyGems we used the version lists in the OSV records. For npm, Go and crates.io, which OSV doesn't enumerate, we asked each registry for its version list. We left out pre-releases, because version-ordering rules for them differ by ecosystem.

Ecosystem2026 reviewed advisoriesWith a stable release in the gapStable releases in gaps
npm3,1953682
PyPI2,24935170
Go2,1172152
Packagist1,4111342
Maven9911927
crates.io472568
NuGet39755
RubyGems18811
Total11,020135447

About one advisory in 80 has a stable release with no verdict. That sounds small until it's your dependency, and the count is a floor: it only includes versions published to each ecosystem's main registry.

Three cases, three outcomes

We checked three gaps against primary sources. They didn't all go the same way.

vm2 3.10.4: the patched version looks wrong. GHSA-qvjj-29qf-hp7p, a sandbox escape through Promise species, gives GitHub's range as up to 3.10.3 and the fix as 3.10.5. The release notes for 3.10.4, published on 4 February 2026, list "prevent sandbox escape via Reflect.construct Promise species bypass". That points to 3.10.4 containing the fix, in which case OSV-based tools are flagging a fixed version.

Angular 19.2.22: the range looks wrong. GHSA-692r-grfm-v8x7, a template namespace bypass in @angular/core, gives the 19.x range as below 19.2.22 and the patched version as 19.2.23. Angular's own repository advisory says the same. But 19.2.22 was published on npm on 12 May 2026, and the second of the two fix pull requests listed under Patches (#68713) was opened on 13 May. A release can't contain a pull request opened after it shipped, so 19.2.22 most likely lacks the complete fix. Here OSV's answer is the safer one, and tools that read the range would miss it.

Hazelcast 5.5.10: the range is right. We covered this one in our Hazelcast advisory teardown. The advisory gives per-branch ranges such as below 5.5.10, but every range lists 5.7.0 as its patched version, so OSV flags the vendor's own fixed Enterprise releases. Hazelcast's advisory names 5.5.10 as a fixed version.

So a gap isn't a bug in one tool. It's a question the advisory didn't answer, and each tool fills it in differently.

Find the gaps in your dependencies

This script reads package-lock.json and pinned requirements.txt files, asks OSV which GitHub advisories affect each pinned version, and reports the ones where GitHub's own range disagrees. It uses only the Python standard library:

#!/usr/bin/env python3
"""Find dependency versions where GitHub's advisory range and OSV's fixed version disagree.

Usage: python3 range_gap.py package-lock.json requirements.txt ...
For each advisory OSV reports against a pinned version, compare that version with GitHub's own
range (kept in OSV as last_known_affected_version_range). Standard library only.
"""
import json
import re
import sys
import urllib.request


def post(url, body):
    req = urllib.request.Request(url, json.dumps(body).encode(), {"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=60) as r:
        return json.load(r)


def get(url):
    with urllib.request.urlopen(url, timeout=60) as r:
        return json.load(r)


def read_deps(path):
    text = open(path, encoding="utf-8").read()
    if path.endswith(".json"):
        for key, meta in json.loads(text).get("packages", {}).items():
            if key.startswith("node_modules/") and "version" in meta:
                yield "npm", key.rsplit("node_modules/", 1)[1], meta["version"]
    else:
        for m in re.finditer(r"^\s*([A-Za-z0-9._-]+)\s*==\s*([^\s;#]+)", text, re.M):
            yield "PyPI", m.group(1).lower(), m.group(2)


def vkey(v):
    # release numbers first; a pre-release tag (1.2.0-rc.1, 1.2.0rc1) sorts before the release
    rel = [int(x) for x in re.findall(r"\d+", re.split(r"[-+]|(?<=\d)(?=[a-zA-Z])", v.lstrip("v"), maxsplit=1)[0])]
    pre = bool(re.search(r"[-]|(?<=\d)(a|b|rc|dev|alpha|beta)", v.lstrip("v")))
    return rel + [0] * (6 - len(rel)), 0 if pre else 1


def in_github_range(version, rng):
    """Evaluate a GitHub range such as '>= 5.5.0, < 5.5.10' or '<= 1.20.0'."""
    for part in rng.split(","):
        m = re.match(r"\s*(>=|>|<=|<|=)\s*(\S+)", part)
        if not m:
            continue
        op, bound = m.groups()
        a, b = vkey(version), vkey(bound)
        ok = {"<": a < b, "<=": a <= b, ">": a > b, ">=": a >= b, "=": a == b}[op]
        if not ok:
            return False
    return True


def main(paths):
    deps = sorted({d for p in paths for d in read_deps(p)})
    queries = [{"package": {"ecosystem": e, "name": n}, "version": v} for e, n, v in deps]
    results = post("https://api.osv.dev/v1/querybatch", {"queries": queries})["results"]
    gaps = 0
    for (eco, name, ver), res in zip(deps, results):
        for hit in res.get("vulns", []):
            if not hit["id"].startswith("GHSA-"):
                continue
            rec = get(f"https://api.osv.dev/v1/vulns/{hit['id']}")
            for a in rec["affected"]:
                if a["package"]["name"].lower() != name.lower():
                    continue
                gh = (a.get("database_specific") or {}).get("last_known_affected_version_range")
                fixed = [e["fixed"] for r in a.get("ranges", []) for e in r.get("events", []) if "fixed" in e]
                if gh and fixed and vkey(ver) < vkey(fixed[-1]) and not in_github_range(ver, gh):
                    intro = [e["introduced"] for r in a.get("ranges", []) for e in r.get("events", []) if "introduced" in e]
                    if intro and intro[0] != "0" and vkey(ver) < vkey(intro[0]):
                        continue
                    gaps += 1
                    print(f"{eco} {name} {ver}: {rec['id']}")
                    print(f"    OSV: affected, fixed in {fixed[-1]}")
                    print(f"    GitHub's range: {gh}, so not affected")
                    print("    no verdict: check the project's release notes or advisory for this version")
    print(f"\n{len(deps)} packages checked, {gaps} advisory matches in a range gap")


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

On two small fixture files with the versions from the cases above, plus two packages with no gap:

$ python3 range_gap.py package-lock.json requirements.txt
PyPI onnx 1.20.1: GHSA-3r9x-f23j-gc73
    OSV: affected, fixed in 1.21.0
    GitHub's range: <= 1.20.0, so not affected
    no verdict: check the project's release notes or advisory for this version
npm @angular/core 19.2.22: GHSA-692r-grfm-v8x7
    OSV: affected, fixed in 19.2.23
    GitHub's range: < 19.2.22, so not affected
    no verdict: check the project's release notes or advisory for this version
npm vm2 3.10.4: GHSA-qvjj-29qf-hp7p
    OSV: affected, fixed in 3.10.5
    GitHub's range: <= 3.10.3, so not affected
    no verdict: check the project's release notes or advisory for this version
npm vm2 3.10.4: GHSA-v37h-5mfm-c47c
    OSV: affected, fixed in 3.11.0
    GitHub's range: <= 3.10.3, so not affected
    no verdict: check the project's release notes or advisory for this version

5 packages checked, 4 advisory matches in a range gap

Every line is a question for the project's release notes, not a verdict. For vm2 3.10.4, the release notes answer it one way. For @angular/core 19.2.22, the pull request dates answer it the other way. vm2 3.10.4 also turns up under a second advisory with the same pattern, which is worth its own check.

Tools that prioritise findings by whether the vulnerable code is reachable help with the rest of the list. Here's Safeguard's interactive walkthrough of reachability analysis cutting scanner noise:

What to do

  1. When two tools disagree about a version, check for a gap first. It's the most common reason a scanner and GitHub's advisory page give different answers for the same advisory.
  2. Resolve the gap from the project, not the tool. Release notes, the fix commit's tags or pull request dates usually settle it in minutes.
  3. When you can't tell, upgrade to the patched version. It's the one version both answers agree is fixed.
  4. Record what you decided. A VEX statement or a note in your tracker keeps the next scanner run from reopening it. Our VEX guide covers the format.
  5. Report the gap. GitHub accepts corrections to advisories through the advisory database repository, and a one-line fix to either the range or the patched version closes the question for everyone.

The fair view

Recording both values is reasonable. A reviewer can be sure a version was affected and sure a later one was fixed without having tested everything in between, and writing both down is more honest than guessing. Most advisories have no gap at all.

The trouble is that the two values reach users through different tools, and each tool presents its answer as the verdict. A version in the gap shows as vulnerable in one dashboard and clean in another, and neither says "unknown". Until advisory data marks these versions as unreviewed, that's a job for the person reading the finding.

Check yourself

An advisory says affected versions are up to 2.4.0 and the patched version is 2.6.0. You run 2.5.0. What do OSV-based tools and GitHub's range each say?

OSV-based tools say affected, because 2.5.0 is below the fixed version. GitHub's range says not affected, because 2.5.0 is above 2.4.0. The advisory doesn't settle it. Check 2.5.0's release notes, or upgrade to 2.6.0.

Which side is usually right in a range gap?

Neither reliably. In our three cases, vm2's release notes suggest the range was right, Angular's pull request dates suggest the patched version was right, and Hazelcast's advisory confirms the range was right. Treat a gap as unknown.

Where does GitHub's range live in OSV data?

In the affected package's database_specific field, as last_known_affected_version_range. OSV-based scanners evaluate the range events instead, which is why their answer can differ from GitHub's advisory page.

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.