# 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

Source: https://socket.dev/glossary/osv

Last updated: 2026-11-02

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](https://socket.dev/glossary/common-vulnerabilities-and-exposures-cve) entry in the [National Vulnerability Database](https://socket.dev/glossary/national-vulnerability-database-nvd) 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](https://security.googleblog.com/2021/02/launching-osv-better-vulnerability.html). The schema's original authors are Oliver Chang and Russ Cox, and it is now maintained in the OpenSSF [osv-schema repository](https://ossf.github.io/osv-schema/). 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](https://socket.dev/glossary/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](https://socket.dev/glossary/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](https://google.github.io/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](https://socket.dev/glossary/semantic-versioning-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](https://socket.dev/blog/nist-nvd-ai-automation) 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.

| Property | NVD CPE matching | OSV records |
| --- | --- | --- |
| Package identity | vendor:product string chosen by an analyst | Ecosystem plus the registry's own package name, optional purl |
| Version semantics | Plain strings with including/excluding bounds | Typed ranges: SEMVER, ECOSYSTEM, or GIT commit hashes, plus explicit version lists |
| Who writes the data | NVD analysts, after a CVE is published | The ecosystem's own advisory database, often with the maintainer |
| Malware coverage | Effectively none. Malicious packages rarely get CVEs | Yes, under the MAL prefix |
| Code-level detail | None | Optional, such as affected Go symbols in ecosystem\_specific |
| Best fit | Commercial products, firmware, operating systems | Open 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.

> How OSV events become affected spans: two introduced/fixed pairs in one range cover the 1.18 and 1.19 release lines.

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'}")
```

```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.

> Three published descriptions of the same vulnerability give different answers for four of eight versions.

| Version | CVE record, CNA data (0 to \<7.5.2) | NVD CPE configuration | GHSA record in OSV format |
| --- | --- | --- | --- |
| 1.0.0 | Affected | Affected | Not affected |
| 5.7.1 | Affected | Affected | Affected |
| 5.7.2 | Affected | Not affected | Not affected |
| 6.3.0 | Affected | Affected | Affected |
| 6.3.1 | Affected | Not affected | Not affected |
| 7.5.1 | Affected | Affected | Affected |
| 7.5.2 | Not affected | Affected | Not affected |
| 7.5.3 | Not affected | Not affected | Not 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](https://registry.npmjs.org/semver), 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](https://nvd.nist.gov/vuln/detail/CVE-2022-25883), 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})
```

```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](https://google.github.io/osv.dev/data/) 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](https://socket.dev/glossary/malicious-package) under the `MAL` prefix, imported from the OpenSSF [Malicious Packages repository](https://github.com/ossf/malicious-packages). 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](https://socket.dev/blog/axios-npm-package-compromised), 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](https://socket.dev/blog/npm-author-qix-compromised-in-major-supply-chain-attack) 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](https://socket.dev/blog/axios-npm-package-compromised), uses a `SEMVER` range with `introduced: "0"` because the package existed only to carry the attack.

> 3 hours 11 minutes passed between the malicious publish and the first OSV record for it.

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](https://socket.dev/glossary/package-firewall) such as [Socket Firewall](https://docs.socket.dev/docs/socket-firewall-overview) 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](https://socket.dev/blog/osv-withdraws-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](https://socket.dev/blog/google-osv-fix-adds-500-new-advisories), 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](https://socket.dev/glossary/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.
