Back to glossary

OSV (Open Source Vulnerabilities)

OSV is an open format and database, OSV.dev, that records which open source package versions a vulnerability or malicious release affects.

Also known as OSV.dev, OSV schema

OSV (Open Source Vulnerabilities) is a JSON format, also called the OSV schema, and the OSV.dev database built on it, for recording which versions of an open source package a vulnerability affects. Each OSV record names a package by ecosystem and name, then lists version events like introduced and fixed, so a scanner can check a lockfile without guessing.

OSV identifies software the way a package manager does. A CVE entry in the National Vulnerability Database identifies software with a CPE product string that an analyst writes by hand. An OSV record says {"ecosystem": "npm", "name": "semver"} and gives version boundaries in that ecosystem's own ordering. Matching becomes a string comparison and a version comparison instead of a mapping exercise.

We think OSV is the right data model for open source vulnerabilities, and that CPE matching should not be the default for packages. OSV is still only a record of what someone reported. Records from different databases disagree, malicious releases get an OSV entry hours after they start executing, and a version match says nothing about whether the vulnerable code ever runs.

What is OSV?

#

OSV is two things that share a name: an open data format and a public aggregation service. Google launched OSV on February 5, 2021. The schema's original authors are Oliver Chang and Russ Cox, and it is now maintained in the OpenSSF osv-schema repository. As of November 2026, the current schema version is 1.9.2, dated October 13, 2026.

The format is federated by design. Each advisory database keeps its own IDs and its own editorial process, then publishes records in the OSV schema. The specification defines 48 database prefixes, including GHSA for the GitHub Advisory Database, PYSEC for PyPI, RUSTSEC for Rust, GO for Go, and MAL for malicious packages. A record ID is always <DB>-<ENTRYID>, such as GHSA-c2qf-rxjj-qqgw or MAL-2026-2307.

OSV.dev is the aggregator. It imports those home databases, converts a few that do not publish OSV natively (NVD CVEs, Debian, and Alpine), expands ranges into explicit version lists where it can, and serves the result through an API and bulk exports. Scanners, software composition analysis tools, and registries consume it. PyPI's JSON API, for example, returns vulnerability entries tagged "source": "osv".

Why does OSV identify packages by ecosystem and name?

#

OSV identifies packages by ecosystem and name because that is the only identity a package manager can check without a lookup table. The NVD describes affected software with Common Platform Enumeration (CPE) strings. For the semver ReDoS bug, CVE-2022-25883, the NVD's configuration names cpe:2.3:a:npmjs:semver:*:*:*:*:*:node.js:*:*. Nothing in npm install produces the vendor string npmjs or the target-software field node.js. A human had to decide those values, and every scanner then has to reverse the mapping.

The OSV.dev FAQ puts it plainly: matching a CVE to a package and version set is "difficult to do in an automated way using existing mechanisms such as CPEs." Version ordering is the second problem. CPE ranges are bare strings with "including" and "excluding" bounds, and comparing them correctly requires knowing whether the ecosystem uses SemVer, PEP 440, Debian epochs, or Maven's ordering. OSV makes the ordering part of the record.

The CVE pipeline's capacity is the third problem. In April 2026, NIST stopped routinely enriching most CVEs with CPE and severity data. As of August 17, 2026, the NVD dashboard listed 42,353 CVEs as "Not Scheduled" for enrichment. A CVE with no CPE configuration cannot be matched by a CPE-based scanner at all. OSV moves that work to the ecosystem databases that already own the package names.

PropertyNVD CPE matchingOSV records
Package identityvendor:product string chosen by an analystEcosystem plus the registry's own package name, optional purl
Version semanticsPlain strings with including/excluding boundsTyped ranges: SEMVER, ECOSYSTEM, or GIT commit hashes, plus explicit version lists
Who writes the dataNVD analysts, after a CVE is publishedThe ecosystem's own advisory database, often with the maintainer
Malware coverageEffectively none. Malicious packages rarely get CVEsYes, under the MAL prefix
Code-level detailNoneOptional, such as affected Go symbols in ecosystem_specific
Best fitCommercial products, firmware, operating systemsOpen source dependencies in a lockfile or SBOM

Verdict: use OSV to match open source dependencies, and keep CPE for products that have no package ecosystem. A scanner that resolves npm, PyPI, or Go dependencies through CPE strings is doing extra work to get a less precise answer.

How does an OSV record describe affected versions?

#

An OSV record describes affected versions as a sorted timeline of events for each package. Every event is exactly one of introduced, fixed, last_affected, or limit. Here is the Go team's record for an HTTP/2 denial-of-service bug, trimmed to the fields that matter:

JSON
{
  "id": "GO-2022-0969",
  "aliases": ["CVE-2022-27664", "GHSA-69cg-p879-7622"],
  "summary": "Denial of service in net/http and golang.org/x/net/http2",
  "affected": [{
    "package": { "name": "stdlib", "ecosystem": "Go" },
    "ranges": [{
      "type": "SEMVER",
      "events": [
        { "introduced": "0" },
        { "fixed": "1.18.6" },
        { "introduced": "1.19.0-0" },
        { "fixed": "1.19.1" }
      ]
    }],
    "ecosystem_specific": {
      "imports": [{ "path": "net/http",
                    "symbols": ["ListenAndServe", "Serve", "Server.ServeTLS", "..."] }]
    }
  }]
}

Read the events left to right as a state machine. introduced: "0" means "before every version," so the flag starts on. fixed: 1.18.6 turns it off. introduced: 1.19.0-0 turns it on again for the 1.19 line, using the lowest possible 1.19 prerelease so that release candidates are included. fixed: 1.19.1 turns it off for good.

The figure plots that range on a number line. Versions from the start of history up to, but not including, 1.18.6 are affected. Versions from 1.18.6 up to 1.19.0-0 are not affected. Versions from 1.19.0-0 up to, but not including, 1.19.1 are affected. Everything from 1.19.1 onward is not affected. Probing three releases gives: 1.18.5 affected, 1.19.0 affected, 1.20.0 not affected.

The range type decides how versions compare. SEMVER uses SemVer 2.0 precedence. ECOSYSTEM means "use this ecosystem's own rules," which is how PyPI and Maven ranges are expressed. GIT uses full commit hashes, where one commit is "less than" another if it is an ancestor, so evaluating it needs a clone of the repository. Because ecosystem-specific ordering is hard to implement everywhere, the specification recommends an explicit versions list as well, and OSV.dev fills one in for the ecosystems it supports.

The specification states the matching rule in one line: "A version is considered affected if it lies within any one of the ranges or is listed in the versions list." The spec also includes pseudocode. We ported it to Python for SemVer-ordered ecosystems:

Python
# Port of the OSV schema's IsVulnerable pseudocode. Requires: pip install semver
import json, sys
from semver import Version

def key(v):
    return Version.parse("0.0.0-0" if v == "0" else v)  # "0" sorts before every version

def included_in_ranges(v, ranges):
    for r in ranges:
        limits = [e["limit"] for e in r["events"] if "limit" in e]
        if limits and not any(lim == "*" or key(v) < key(lim) for lim in limits):
            continue
        vulnerable = False
        events = [e for e in r["events"] if "limit" not in e]
        for e in sorted(events, key=lambda e: key(next(iter(e.values())))):
            if "introduced" in e and key(v) >= key(e["introduced"]):
                vulnerable = True
            elif "fixed" in e and key(v) >= key(e["fixed"]):
                vulnerable = False
            elif "last_affected" in e and key(v) > key(e["last_affected"]):
                vulnerable = False
        if vulnerable:
            return True
    return False

def is_vulnerable(name, v, osv):
    return any(a["package"]["name"] == name and
               (v in a.get("versions", []) or included_in_ranges(v, a.get("ranges", [])))
               for a in osv["affected"])

osv = json.load(open(sys.argv[1]))
for v in sys.argv[3:]:
    print(f"{sys.argv[2]}@{v}: {'AFFECTED' if is_vulnerable(sys.argv[2], v, osv) else 'not affected'}")
Plain text
$ python3 is_vulnerable.py GO-2022-0969.json stdlib 1.18.5 1.18.6 1.19.0 1.19.1 1.20.0
stdlib@1.18.5: AFFECTED
stdlib@1.18.6: not affected
stdlib@1.19.0: AFFECTED
stdlib@1.19.1: not affected
stdlib@1.20.0: not affected

Two details trip up hand-written matchers. First, fixed is exclusive and last_affected is inclusive. The spec prefers fixed because a last_affected ceiling silently clears any later version that was never patched. Second, limit caps a range from above for Git branches, and a range with no limit is treated as unbounded.

One CVE, three answers: the semver ReDoS

#

OSV records from different sources often disagree, and the format does not reconcile them. CVE-2022-25883, a regular expression denial of service in npm's semver package, shows how. Three public descriptions of the same bug exist, and we checked eight versions against each one.

VersionCVE record, CNA data (0 to <7.5.2)NVD CPE configurationGHSA record in OSV format
1.0.0AffectedAffectedNot affected
5.7.1AffectedAffectedAffected
5.7.2AffectedNot affectedNot affected
6.3.0AffectedAffectedAffected
6.3.1AffectedNot affectedNot affected
7.5.1AffectedAffectedAffected
7.5.2Not affectedAffectedNot affected
7.5.3Not affectedNot affectedNot affected

The three sources disagree on four of the eight versions: 1.0.0, 5.7.2, 6.3.1, and 7.5.2. Each disagreement has a mechanical cause.

The CVE record was published on June 21, 2023, when 7.5.2 was the only fixed release. The CNA wrote one range, 0 to less than 7.5.2. The backports, semver 5.7.2 and 6.3.1, reached npm on July 10, 2023, and the CVE record's affected range was never split. A scanner that trusts it flags every 5.x and 6.x install, including patched ones.

The NVD's CPE configuration splits the three release lines. According to the NVD change history, a September 23, 2025 modified analysis moved the 7.x upper bound from 7.5.2 to 7.5.3, so it flags 7.5.2. Its 5.x range has no lower bound, so it also flags 1.0.0.

The GHSA record, which OSV.dev serves, uses three affected entries with fixed events at 5.7.2, 6.3.1, and 7.5.2. Its oldest range starts at introduced: "2.0.0-alpha", so it clears 1.0.0.

OSV did not cause the disagreement and cannot resolve it. What OSV does is make each claim precise enough to diff. Our recommendation: match with the ecosystem's own record, keep the source ID on every finding, and treat a disagreement between sources as a signal to read the fix commits.

Aliases, duplicates, and the OSV.dev API

#

OSV.dev handles duplicate records with three relationship fields: aliases, upstream, and related. An alias means two IDs describe the same vulnerability, and aliases are symmetric and transitive. upstream points from a downstream advisory, like a Linux distribution's, to the library vulnerability it inherits, and is transitive but not symmetric. related covers everything else that is similar but distinct.

Aliases are not optional bookkeeping. PyPI's JSON API returns OSV-sourced entries per release, and counting them naively doubles the number. We queried jinja2 2.4.1 and grouped the results by alias:

Python
# Collapse PyPI's OSV-sourced vulnerability list into alias groups.
import json, sys, urllib.request
pkg, ver = sys.argv[1], sys.argv[2]
vulns = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/{pkg}/{ver}/json"))["vulnerabilities"]
parent = {}
def find(x):
    parent.setdefault(x, x)
    while parent[x] != x:
        x = parent[x]
    return x
for v in vulns:
    for a in v["aliases"]:
        parent[find(a)] = find(v["id"])
groups = {}
for v in vulns:
    groups.setdefault(find(v["id"]), []).append(v)
print(f"{len(vulns)} records, {len(groups)} distinct vulnerabilities")
for g in groups.values():
    print(" ", sorted(v["id"] for v in g), {v["id"]: v["fixed_in"] for v in g})
Plain text
$ python3 group_aliases.py jinja2 2.4.1
18 records, 9 distinct vulnerabilities
  ['GHSA-fqh9-2qgg-h84h', 'PYSEC-2014-82'] {'PYSEC-2014-82': ['2.7.3'], 'GHSA-fqh9-2qgg-h84h': ['2.7.2']}
  ['GHSA-8r7q-cvjq-x353', 'PYSEC-2014-8'] {'PYSEC-2014-8': ['2.7.2'], 'GHSA-8r7q-cvjq-x353': ['2.7.2']}
  ...

As of November 2, 2026, jinja2 2.4.1 returns 18 records that collapse to 9 vulnerabilities. Every vulnerability appears once as a PYSEC record and once as a GHSA record. One alias pair also disagrees on the fix: for CVE-2014-0012, the PyPI record says the fix is in 2.7.3 and the GitHub record says 2.7.2. A tool that deduplicates by alias still has to choose which fix version to report.

OSV.dev's own interface is a small HTTP API and a bucket. POST /v1/query takes a package and version, or a bare commit hash, and returns full records. POST /v1/querybatch accepts many queries but returns only id and modified per match, so a scanner fetches full records separately with GET /v1/vulns/{id}:

Bash
curl -d '{"package": {"name": "semver", "ecosystem": "npm"}, "version": "6.3.0"}' \
  https://api.osv.dev/v1/query

For offline use, OSV.dev exports every record to gs://osv-vulnerabilities/<ECOSYSTEM>/all.zip, with a reverse-chronological modified_id.csv for incremental sync. The OSV.dev data documentation describes the export, and the FAQ sets a service level objective of data sources "no more than 15 minutes stale, 99.5% of the time." That figure measures the delay between a home database and OSV.dev. It says nothing about the delay between an attack and the home database.

Does OSV track malicious packages?

#

OSV tracks malicious packages under the MAL prefix, imported from the OpenSSF Malicious Packages repository. As of November 2, 2026, that repository holds 239,225 active malware reports. 222,632 of them, about 93%, are for npm. Malicious packages generally do not get CVEs, so for most supply chain attacks a MAL record is the only machine-readable ID that exists.

A MAL record looks like any other OSV record, with two differences. A package that was malicious from the start usually gets introduced: "0", meaning every version. A compromised legitimate package gets an explicit versions list instead. Here is the record for the axios compromise, trimmed:

JSON
{
  "id": "MAL-2026-2307",
  "aliases": ["GHSA-fw8c-xr5c-95f9"],
  "summary": "Malicious code in axios (npm)",
  "published": "2026-03-31T03:15:49Z",
  "affected": [{
    "package": { "ecosystem": "npm", "name": "axios" },
    "versions": ["0.30.4", "1.14.1"]
  }],
  "database_specific": {
    "iocs": {
      "domains": ["sfrclak.com"],
      "ips": ["142.11.206.73"],
      "urls": ["http://sfrclak.com:8000/6202033"]
    }
  }
}

The aliases list a GHSA ID and no CVE. The record for chalk from the Qix maintainer compromise in September 2025 has the same shape: MAL-2025-46969 lists one version, 5.6.1. The record for the dependency that carried the axios payload, MAL-2026-2306 for plain-crypto-js, uses a SEMVER range with introduced: "0" because the package existed only to carry the attack.

The figure lays out the axios timeline in UTC from npm registry timestamps, Socket's published analysis, and the MAL records' import times. plain-crypto-js@4.2.1 was published at 23:59:12 on March 30, 2026. Socket's automated malware detection flagged it at 00:05:41 on March 31, six minutes later. axios@1.14.1, which added plain-crypto-js as a dependency, was published at 00:21:58, and axios@0.30.4 at 01:00:57. The first MAL record, MAL-2026-2306 for plain-crypto-js, was imported into the Malicious Packages repository at 03:10:11. MAL-2026-2307 for axios followed at 03:22:12. npm published its 0.0.1-security placeholder for plain-crypto-js at 03:25:11.

So for 3 hours and 11 minutes after the malicious publish, an OSV-based check would have reported nothing for any affected install. That gap is not a flaw in OSV. A MAL record is written after someone finds and reports malware, and the payload ran in a postinstall hook at install time. This is why we analyze what is inside a package before install instead of waiting for an ID. A package firewall such as Socket Firewall sits between the package manager and the registry and blocks a request for a package that has a malware verdict, at any dependency depth. That covers npx runs and fresh installs that a lockfile scan never sees. It is only as good as the detection behind it, and it cannot block a release nobody has flagged yet.

The feed can also be wrong in the other direction. On May 26, 2026, the repository withdrew 157 malware reports after automated detections, many credited to Amazon Inspector, flagged legitimate npm and PyPI releases, including FastAPI 0.136.3 as MAL-2026-4750. A CI gate that blocks on any MAL match would have failed builds for packages that were never compromised. Withdrawn records keep their IDs and gain a withdrawn timestamp, so consumers have to honor that field.

Conversion bugs affect ordinary vulnerabilities too. OSV's NVD converter used to treat CVEs marked "disputed" as withdrawn, which hid CVE-2023-48022, an actively exploited flaw in Ray. After Socket's Jonathan Leitschuh raised it with the OSV maintainers in August 2025, the fix restored more than 500 advisories.

What OSV does not tell you

#

OSV tells you that a version is in a range. It does not tell you that your program calls the vulnerable code. Many version-matched alerts concern functions the application never calls, which is why we think reachability analysis, not severity score, should drive triage.

The Go database is the clearest example of OSV carrying reachability data. GO-2022-0969 lists 10 affected net/http symbols in ecosystem_specific.imports, and govulncheck uses that list to report the bug only if a call path reaches one of them. Many databases leave that field empty, so a scanner has to recover the vulnerable function from the fix commit itself.

OSV also matches only what you show it. A lockfile scan sees the versions the lockfile recorded. Code fetched by npx, installed globally, or resolved in an ephemeral CI job is not in that lockfile, and a clean result after the malicious version was unpublished does not prove it never ran.

OSV has largely solved the data problem it was built for: given a package and a version, any tool can now answer "is this affected?" from a precise, machine-readable record. The open problem is the window before the record exists. For the axios attack, that window was 3 hours and 11 minutes. For the next compromised package it might be shorter, but it will not be zero, and every install that resolves a new version during it will pass an OSV check.