OSV publishes a zip file of every record for each package ecosystem. With about 30 lines of Python you can download one, count malicious records, find advisories without a CVE alias and measure how many lack a fixed version. This guide gives the script and the pitfalls we hit.
Where the data lives
The OSV data page documents per-ecosystem downloads. Each ecosystem has an all.zip under a public bucket, named by ecosystem, for example npm, PyPI, Go, Maven, NuGet, Packagist, RubyGems and crates.io. Each zip holds one JSON file per record, following the OSV schema.
Sizes differ a lot. On 19 October 2026 the npm export was about 218 MB, the PyPI one about 36 MB and RubyGems about 5 MB. Start with a small ecosystem.
Some terms from the schema help. id is the record's own ID. aliases lists other IDs for the same flaw. withdrawn is a timestamp present only if the record was withdrawn. affected holds package names and ranges, and each range has events such as introduced and fixed.
The script
import io, json, sys, urllib.request, zipfile
from collections import Counter
eco = sys.argv[1] if len(sys.argv) > 1 else "RubyGems"
url = f"https://osv-vulnerabilities.storage.googleapis.com/{eco}/all.zip"
zf = zipfile.ZipFile(io.BytesIO(urllib.request.urlopen(url).read()))
records = [json.loads(zf.read(n)) for n in zf.namelist() if n.endswith(".json")]
live = [r for r in records if not r.get("withdrawn")]
malicious = [r for r in live if r["id"].startswith("MAL-")]
vulns = [r for r in live if not r["id"].startswith("MAL-")]
print(eco, "records", len(records), "malicious", len(malicious), "advisories", len(vulns))
no_cve = sum(1 for r in vulns if not any(a.startswith("CVE-") for a in r.get("aliases", [])))
print("no CVE alias:", round(100 * no_cve / len(vulns), 1), "percent")
def has_fix(r):
return any("fixed" in ev for a in r.get("affected", [])
for rg in a.get("ranges", []) for ev in rg.get("events", []))
print("no fixed version:", round(100 * sum(not has_fix(r) for r in vulns) / len(vulns), 1), "percent")
years = Counter(r["published"][:4] for r in vulns)
print("by year:", sorted(years.items())[-4:])
Run it as python3 script.py RubyGems. When we ran it on 19 October 2026 it printed 5,464 records, 4,304 malicious, 1,091 advisories, 3.2 percent with no CVE alias, 6.1 percent with no fixed version, and 188 advisories published in 2026. The records change daily, so your numbers may differ slightly.
Pitfalls we hit
Four mistakes are easy to make.
First, mixing record types. Records starting with MAL- are malicious package reports, not vulnerabilities. If you do not separate them, npm looks like it has 230,000 advisories.
Second, forgetting withdrawn records. They stay in the export with a withdrawn timestamp. Leave them in and every count is slightly high.
Third, sentinel versions. introduced of "0" means the start of history, and crates.io records also use "0.0.0-0". A check for only one of them undercounts.
Fourth, counting records as vulnerabilities. In PyPI, Go and crates.io the same flaw often has two records with different IDs. If you want distinct flaws, group by shared aliases first. See our duplicate advisories analysis.
Checking a claim
When you read a number about an ecosystem, ask which of these choices the author made. A count of malicious records, a count of advisories and a count of distinct CVEs can differ by an order of magnitude for the same ecosystem, and all three can be correct for their definition.
If you would rather match a dependency list against this data than count it, Safeguard's SBOM and dependency scanning does that, and the free scan at https://gold.safeguard.sh/scan shows a first result.
Quick check
Which ecosystem should I start with?
A small one such as RubyGems, which is about 5 MB. The npm file is over 200 MB.
Why exclude withdrawn records?
They were retracted by the source, so counting them overstates the number of live advisories.
Sources
- OSV data sources and per-ecosystem downloads, fetched 19 October 2026
- OSV schema, fetched 19 October 2026
Last reviewed: 19 October 2026.