Fairquery

How do I use the OSV API in Python to check packages for vulnerabilities (query, batch, pagination)?

Fairquery · · 9 min read

Short answer: OSV.dev has a free JSON API with no key and, per its docs, no rate limits. In Python, POST one package and exact version to https://api.osv.dev/v1/query to get full advisories, or send many at once to /v1/querybatch, which returns only advisory IDs, then fetch details from GET /v1/vulns/{id}. Handle three traps: follow next_page_token, de-duplicate aliases (one CVE often appears as both a GHSA- and a PYSEC- record), and decide which severity field you trust. The standard-library script below does all of it. If you'd rather get finished rows with computed CVSS scores, a severity filter and a recommended upgrade, the Fairquery OSV Dependency Vulnerability Check costs $0.0005 per package and $0.0015 per vulnerability row.

The three OSV endpoints you need

EndpointReturnsUse it for
POST /v1/queryFull advisory objects for one package version (or one commit)A single lookup
POST /v1/querybatchOnly id and modified per advisory, results in the same order as your queriesMany packages in one request
GET /v1/vulns/{id}One full advisory: summary, aliases, severity, affected ranges and fixed versionsDetails after a batch query

Facts from the OSV API docs worth knowing before you write code:

Option 1 (free): one package with /v1/query

import json, urllib.request

def osv(path, payload=None):
    req = urllib.request.Request(
        "https://api.osv.dev/v1/" + path,
        data=json.dumps(payload).encode() if payload is not None else None,
        headers={"Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.load(r)

res = osv("query", {"package": {"ecosystem": "PyPI", "name": "jinja2"}, "version": "2.11.2"})
for v in res.get("vulns", []):
    print(v["id"], v.get("aliases", []), v.get("summary", "")[:60])

Real output (October 18, 2026, about 0.2 seconds):

GHSA-cpwx-vrp4-4pq7 ['CVE-2025-27516', 'PYSEC-2026-1471'] Jinja2 vulnerable to sandbox breakout through attr filter se
GHSA-g3rq-g295-4j3m ['CVE-2020-28493', 'PYSEC-2021-66', 'SNYK-PYTHON-JINJA2-1012994'] Regular Expression Denial of Service (ReDoS) in Jinja2
GHSA-h5c8-rqwp-cp95 ['CVE-2024-22195', 'PYSEC-2026-1473'] Jinja vulnerable to HTML attribute injection when passing us
GHSA-h75v-3vvj-5mfj ['CVE-2024-34064', 'PYSEC-2026-1474'] Jinja vulnerable to HTML attribute injection when passing us
GHSA-q2x7-8rv6-6q7h ['CVE-2024-56326', 'PYSEC-2026-1475'] Jinja has a sandbox breakout through indirect reference to f
PYSEC-2021-66 ['CVE-2020-28493', 'GHSA-g3rq-g295-4j3m', 'SNYK-PYTHON-JINJA2-1012994'] 
PYSEC-2026-1471 ['CVE-2025-27516', 'GHSA-cpwx-vrp4-4pq7'] Jinja2 vulnerable to sandbox breakout through attr filter se
PYSEC-2026-1473 ['CVE-2024-22195', 'GHSA-h5c8-rqwp-cp95'] Jinja vulnerable to HTML attribute injection when passing us
PYSEC-2026-1474 ['CVE-2024-34064', 'GHSA-h75v-3vvj-5mfj'] Jinja vulnerable to HTML attribute injection when passing us
PYSEC-2026-1475 ['CVE-2024-56326', 'GHSA-q2x7-8rv6-6q7h'] Jinja has a sandbox breakout through indirect reference to f

Ten records, but only five vulnerabilities: each CVE appears once as a GitHub advisory (GHSA-…) and once as a PyPA advisory (PYSEC-…), and each lists the other in aliases. Count records naively and you double your findings.

Option 1b (free): a whole requirements file with querybatch

This script reads exact pins from requirements.txt, sends them to querybatch in chunks of 1,000, follows next_page_token per query, fetches each unique advisory once with 8 parallel requests, collapses aliases (preferring the GHSA record), prints the CVE, the GHSA severity label and the fixed versions, and exits with code 1 if anything was found, so you can use it as a CI gate:

"""Check pinned packages against OSV.dev: querybatch + pagination + details + alias de-dup.
Standard library only (Python 3.9+). Usage: python3 osv_batch.py requirements.txt"""
import json, sys, urllib.request
from concurrent.futures import ThreadPoolExecutor

API = "https://api.osv.dev/v1/"

def osv(path, payload=None):
    req = urllib.request.Request(API + path, headers={"Content-Type": "application/json"},
                                 data=json.dumps(payload).encode() if payload is not None else None)
    with urllib.request.urlopen(req, timeout=60) as r:
        return json.load(r)

def read_pins(path, ecosystem="PyPI"):
    pins = []
    for line in open(path):
        line = line.split("#")[0].strip()
        if "==" in line:  # only exact pins: OSV needs a concrete version
            name, version = line.split("==", 1)
            pins.append({"package": {"ecosystem": ecosystem, "name": name.strip()}, "version": version.strip()})
        elif line:
            print(f"skipped (not pinned with ==): {line}", file=sys.stderr)
    return pins

def query_batch(queries):
    """Return one list of vuln IDs per query, following next_page_token where present."""
    ids = [[] for _ in queries]
    for start in range(0, len(queries), 1000):  # keep batches at 1,000 queries
        todo = {i: dict(q) for i, q in enumerate(queries[start:start + 1000], start)}
        while todo:
            order = list(todo)
            res = osv("querybatch", {"queries": [todo[i] for i in order]})["results"]
            nxt = {}
            for i, r in zip(order, res):
                ids[i] += [v["id"] for v in r.get("vulns", [])]
                if r.get("next_page_token"):
                    nxt[i] = {**todo[i], "page_token": r["next_page_token"]}
            todo = nxt
    return ids

def fixed_versions(vuln, name):
    out = set()
    for aff in vuln.get("affected", []):
        if aff.get("package", {}).get("name", "").lower() != name.lower():
            continue
        for rng in aff.get("ranges", []):
            out.update(e["fixed"] for e in rng.get("events", []) if "fixed" in e)
    return sorted(out)

def main(path):
    pins = read_pins(path)
    ids = query_batch(pins)
    unique = sorted({i for row in ids for i in row})
    with ThreadPoolExecutor(max_workers=8) as pool:  # be polite: a few parallel requests
        details = dict(zip(unique, pool.map(lambda i: osv("vulns/" + i), unique)))
    findings = 0
    for pin, row in zip(pins, ids):
        name, version = pin["package"]["name"], pin["version"]
        seen, groups = set(), []
        for vid in sorted(row, key=lambda x: (not x.startswith("GHSA-"), x)):  # prefer GHSA records
            v = details[vid]
            group = {vid, *v.get("aliases", [])}
            if group & seen:
                continue  # same advisory under another ID (e.g. PYSEC-... vs GHSA-...)
            seen |= group
            groups.append(v)
        print(f"{name}=={version}: {len(groups)} advisories ({len(row)} OSV IDs)")
        for v in groups:
            cve = next((a for a in v.get("aliases", []) if a.startswith("CVE-")), "-")
            sev = v.get("database_specific", {}).get("severity", "n/a")
            fix = ", ".join(fixed_versions(v, name)) or "none listed"
            print(f"  {v['id']:<20} {cve:<15} {sev:<9} fixed: {fix}")
        findings += len(groups)
    return 1 if findings else 0

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

Input file:

requests==2.25.0
jinja2==2.11.2
urllib3==2.2.3
flask>=2.0

Real output (October 18, 2026; 1 batch request plus 34 detail requests, under 1 second end to end in our runs):

skipped (not pinned with ==): flask>=2.0
requests==2.25.0: 4 advisories (8 OSV IDs)
  GHSA-9hjg-9r4m-mvj7  CVE-2024-47081  MODERATE  fixed: 2.32.4
  GHSA-9wx4-h78v-vm56  CVE-2024-35195  MODERATE  fixed: 2.32.0
  GHSA-gc5v-m9x4-r6x2  CVE-2026-25645  MODERATE  fixed: 2.33.0
  GHSA-j8r2-6x86-q33q  CVE-2023-32681  MODERATE  fixed: 2.31.0
jinja2==2.11.2: 5 advisories (10 OSV IDs)
  GHSA-cpwx-vrp4-4pq7  CVE-2025-27516  MODERATE  fixed: 3.1.6
  GHSA-g3rq-g295-4j3m  CVE-2020-28493  MODERATE  fixed: 2.11.3
  GHSA-h5c8-rqwp-cp95  CVE-2024-22195  MODERATE  fixed: 3.1.3
  GHSA-h75v-3vvj-5mfj  CVE-2024-34064  MODERATE  fixed: 3.1.4
  GHSA-q2x7-8rv6-6q7h  CVE-2024-56326  MODERATE  fixed: 3.1.5
urllib3==2.2.3: 8 advisories (16 OSV IDs)
  GHSA-2xpw-w6gg-jr37  CVE-2025-66471  HIGH      fixed: 2.6.0
  GHSA-38jv-5279-wg99  CVE-2026-21441  HIGH      fixed: 2.6.3
  GHSA-48p4-8xcf-vxj5  CVE-2025-50182  MODERATE  fixed: 2.5.0
  GHSA-8988-9cw3-xx77  CVE-2026-97687  HIGH      fixed: 2.8.0
  GHSA-gm62-xv2j-4w53  CVE-2025-66418  HIGH      fixed: 2.6.0
  GHSA-pq67-6m6q-mj2v  CVE-2025-50181  MODERATE  fixed: 2.5.0
  GHSA-qccp-gfcp-xxvc  CVE-2026-44431  HIGH      fixed: 2.7.0
  GHSA-vxq7-64xx-v4gw  CVE-2026-97689  HIGH      fixed: 2.8.0
exit=1

Three things to read from it. The unpinned flask>=2.0 line is skipped with a warning rather than guessed. Raw OSV IDs (34) collapse to 17 distinct advisories. And "fixed" is a list per advisory: for packages with several release lines (Django, for example, lists fixes such as 4.2.28, 5.2.11, 6.0.2) pick the fix on your own line, or the highest one if you can upgrade freely.

Trap: which severity do you trust?

OSV records can carry several severity signals: a severity array with CVSS vectors (CVSS_V3, CVSS_V4) and, for GitHub advisories, a database_specific.severity label. They don't always agree. In the run above, GitHub labels GHSA-q2x7-8rv6-6q7h (Jinja sandbox breakout) as MODERATE, but its CVSS 3.1 vector AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H computes to a base score of 7.8, which is HIGH. For GHSA-qccp-gfcp-xxvc (urllib3) it's the other way round: the label is HIGH while its CVSS 3.1 vector scores 5.3 (MEDIUM). OSV gives you vectors, not scores, so computing a number needs a CVSS calculator (for example the cvss package on PyPI).

Pick one rule, document it, and apply it consistently. A CI gate on "HIGH or above" will pass or fail different advisories depending on the field you read.

Option 2 (API, MCP or CI): finished rows

The Fairquery OSV Dependency Vulnerability Check (fairquery/osv-dependency-vulnerability-check) wraps the same endpoints. It parses requirements.txt, package.json, package-lock.json (v1 to v3) and go.mod, computes the CVSS v3 base score when a v3 vector exists and otherwise falls back to the database label, filters by minSeverity, de-duplicates aliases and recommends an upgrade version. Input for the same three pinned packages:

{"manifestText": "requests==2.25.0\njinja2==2.11.2\nurllib3==2.2.3\n", "minSeverity": "HIGH"}

We ran the Actor's published source locally on October 18, 2026. It made 35 OSV requests and returned 3 package rows and 6 vulnerability rows (17 alias duplicates removed, 11 advisories below HIGH filtered out). One vulnerability row, abridged:

{
  "type": "vulnerability",
  "id": "GHSA-q2x7-8rv6-6q7h",
  "ecosystem": "PyPI",
  "package_name": "jinja2",
  "package_version": "2.11.2",
  "aliases": [
    "CVE-2024-56326",
    "PYSEC-2026-1475"
  ],
  "cve_ids": [
    "CVE-2024-56326"
  ],
  "summary": "Jinja has a sandbox breakout through indirect reference to format method",
  "severity": "HIGH",
  "severity_source": "cvss",
  "cvss_score": 7.8,
  "cvss_vector": "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H",
  "cvss_version": "3.1",
  "fixed_versions": [
    "3.1.5"
  ],
  "osv_url": "https://osv.dev/vulnerability/GHSA-q2x7-8rv6-6q7h"
}

Note severity_source: "cvss": this row is HIGH because of the computed 7.8, even though GitHub's label says MODERATE, which is exactly the disagreement described above. The same run would cost 3 × $0.0005 + 6 × $0.0015 = $0.0105, plus Apify's $0.00005 Actor-start fee. For a GitHub Actions job that fails the build on HIGH or CRITICAL, and for the free osv_query_packages MCP tool, see how to check npm and PyPI dependencies for vulnerabilities in CI with OSV.

Which option should you use?

OptionGood forTrade-offs
Your own Python against the OSV API (above)Free, no dependencies, full control over severity rulesYou maintain manifest parsing, alias handling, CVSS scoring and pagination
Google's osv-scanner CLIFree, official, scans lockfiles, SBOMs and container images locallyA binary to install in CI; not a hosted API or MCP tool
Fairquery OSV ActorOne API or MCP call from agents, n8n or CI; manifest parsing, computed CVSS, severity filter, recommended version as JSON rows, pay per rowPaid per package and vulnerability; needs an Apify account; same OSV data, no reachability analysis

FAQ

Is the OSV API free and does it have rate limits?

It is free and needs no API key. The OSV docs state that there are currently no limits on the API; responses are capped at 32 MiB over HTTP/1.1 and uncapped over HTTP/2. Keep parallel requests modest anyway.

What is the difference between /v1/query and /v1/querybatch?

/v1/query returns full advisory objects for one package version or commit. /v1/querybatch takes many queries and returns only the advisory id and modified time for each, in input order; fetch details with GET /v1/vulns/{id}.

How does OSV pagination work?

A response that has more results includes next_page_token. Re-send the same query with page_token set to it until no token comes back. In querybatch only some queries may be paginated, so re-send just those.

Why does OSV return the same vulnerability twice?

Several databases publish the same issue: a GitHub advisory (GHSA-...) and a PyPA advisory (PYSEC-...) for one CVE, each listing the other in aliases. Group records by their aliases before counting.

Can I query OSV with a version range like >=2.0?

No. OSV matches exact versions. Send concrete versions from a lockfile or pip freeze; a range will not give a correct answer.

How much does the Fairquery OSV Actor cost?

$0.0005 per package checked plus $0.0015 per vulnerability row emitted after the severity filter and alias de-duplication, plus Apify's $0.00005 Actor-start fee. Error and warning rows are free. The Python scripts in this guide are free.

No result is not proof of safety. An empty response means OSV has no matching advisory for that exact version. No database is complete, and none of these options checks whether the vulnerable code is reachable in your application.

Source and docs: github.com/fairquery/osv-dependency-vulnerability-check (MIT) · Apify Store · OSV API docs · all Fairquery tools