Safeguard
Supply Chain Security•

2,128 Malicious Package Reports in 46 Days, Counted by Ecosystem

OSV published 2,128 MAL malicious-package reports from 1 Sep to 16 Oct 2026. npm had 1,299 and RubyGems 677, and one afternoon produced 611 RubyGems entries.

Safeguard Security Team
7 min read

Between 1 September and 16 October 2026, the OSV database published 2,128 malicious package reports. These are the records whose IDs start with MAL-, fed by the OpenSSF malicious-packages project. npm accounts for 1,299 of them and RubyGems for 677. PyPI has 142.

We counted them from public bulk files downloaded on 18 October 2026. The method is below, so you can rerun it and get the same numbers for the same window.

How we counted

OSV publishes one zip per ecosystem in a public Google Cloud Storage bucket. Each zip holds one JSON file per record. We downloaded the zips for npm, PyPI, RubyGems, NuGet, Go, crates.io, Maven, Packagist, Pub, Hex, GitHub Actions, VSCode, Hackage and CRAN (the curl loop below omits GitHub Actions, which had no MAL- entries), then kept records whose published date falls from 2026-09-01 to 2026-10-16 inclusive (UTC).

for e in npm PyPI RubyGems NuGet Go crates.io Maven Packagist Pub Hex VSCode Hackage CRAN; do
  curl -sS -o "$e.zip" "https://osv-vulnerabilities.storage.googleapis.com/$e/all.zip"
done
import zipfile, json, collections

mal = []
for eco in ["npm", "PyPI", "RubyGems", "NuGet", "Go", "crates.io", "Maven",
            "Packagist", "Pub", "Hex", "VSCode", "Hackage", "CRAN"]:
    z = zipfile.ZipFile(f"{eco}.zip")
    for name in z.namelist():
        if not name.startswith("MAL-"):
            continue
        d = json.loads(z.read(name))
        if "2026-09-01" <= d["published"][:10] <= "2026-10-16":
            d["_eco"] = eco
            mal.append(d)

print(len(mal), "reports,", len({d["id"] for d in mal}), "unique IDs")
print(collections.Counter(d["_eco"] for d in mal).most_common())

We counted by the zip an entry came from. All 2,128 IDs are unique, and none is marked withdrawn. For the comparison column we counted GitHub advisory (GHSA-) records in the same zips and window, excluding withdrawn ones.

What's affected

EcosystemMalicious reportsShareGHSA vulnerability advisories, same window
npm1,29961.0%533
RubyGems67731.8%11
PyPI1426.7%359
NuGet50.2%75
Go20.1%313
crates.io20.1%45
VSCode1under 0.1%0
Total2,128

Maven, Packagist, Pub, Hex, GitHub Actions, Hackage and CRAN had no MAL- reports in the window.

Two things stand out. On npm there were more malware reports than vulnerability advisories, by about 2.4 to 1. On RubyGems the gap is far larger, 677 to 11, which is a different kind of risk from the one a CVE feed shows you.

One afternoon moved the totals

The weekly counts are not smooth. Weeks start on Monday, so the first and last rows are partial.

Week startingReports
2026-08-31 (from 1 Sep)229
2026-09-07140
2026-09-14150
2026-09-21899
2026-09-28287
2026-10-05302
2026-10-12 (to 16 Oct)121

Of the 899 in the week of 21 September, 715 were published on 22 September alone. 611 of those were RubyGems entries, published between 16:05 and 23:20 UTC, and 608 of the 611 cite only the reversing-labs source. Without that batch, RubyGems would have 66 reports in the window. Every one of the 46 days in the window had at least one report.

Who reported them

Each record can list one or more origins in database_specific.malicious-packages-origins. Counting records that cite each source:

SourceRecords citing it
amazon-inspector1,054
reversing-labs672
ghsa-malware599
kam193130
ossf-package-analysis107
no origin listed77

These add up to more than 2,128 because 497 records cite more than one source. All 599 ghsa-malware records are npm, and each carries a GHSA- alias. The 77 records with no origin are mostly npm (68).

What the records contain

263 records carry indicators of compromise in database_specific.iocs: 144 npm, 72 PyPI, 42 RubyGems, 2 Go, 2 crates.io and 1 VSCode. The median record lists one affected version, and 239 records list no specific version. 474 of the 1,299 npm reports (36.5%) are scoped packages whose names start with @.

How to check if you're exposed

OSV's API can check a package name against these records. This script reads an npm package-lock.json (lockfile v2 or later) and prints any package that has a MAL- ID. We ran it against a sample lockfile.

import json, sys, urllib.request

lock = json.load(open(sys.argv[1]))
names = sorted({p.split("node_modules/")[-1] for p in lock["packages"] if p})
body = {"queries": [{"package": {"name": n, "ecosystem": "npm"}} for n in names]}
req = urllib.request.Request("https://api.osv.dev/v1/querybatch", json.dumps(body).encode())
res = json.load(urllib.request.urlopen(req))["results"]
for n, r in zip(names, res):
    mal = [v["id"] for v in r.get("vulns", []) if v["id"].startswith("MAL-")]
    if mal:
        print(n, mal)
print(len(names), "packages checked")

For a single package, curl -s -X POST https://api.osv.dev/v1/query -d '{"package":{"name":"NAME","ecosystem":"npm"}}' returns every record, malware included. Note that the query matches by name, so a hit means "a malicious version exists", and you still need to compare versions.

What to do now

  1. Run the check on every lockfile you ship from, including Gemfile.lock and requirements.txt equivalents, using the matching ecosystem name in the query.
  2. Treat any MAL- hit as an incident until proven otherwise. Read the record's details, compare the listed versions with your lockfile, and look at the iocs field for domains and file hashes to search for in logs.
  3. Rotate secrets on any machine that installed a hit. Install-time scripts run with the developer's or CI runner's environment.
  4. Use a cooldown for new versions. Waiting a few days before adopting a brand new release gives registries and scanners time to flag a bad one.
  5. Alert on MAL IDs separately from CVEs. A CVE feed will miss everything in the table above.

Why it matters

Vulnerability counts are the number most programs track, and in this window they were the smaller problem for npm and RubyGems. A package that is malicious from its first release has no fixed version and no CVSS score, so a scanner that only reads CVEs will not flag it.

The data also shows how lumpy detection is. One source added 608 RubyGems reports in an afternoon. Counts like these tell you what was reported, not how many malicious packages were published, so read them as a floor on that second number.

Try it yourself

Pick one lockfile from your busiest repo and run the script above. Then look up a package you added in the last month in the Gold directory at https://gold.safeguard.sh, which lists malicious packages alongside CVEs. The demo below walks through spotting typosquats and malware.

Click through it at your own pace, or open it full screen.

Quick check

How many MAL reports did OSV publish from 1 Sep to 16 Oct 2026?

2,128, all with unique IDs and none withdrawn: 1,299 npm, 677 RubyGems, 142 PyPI, 5 NuGet, 2 Go, 2 crates.io and 1 VSCode.

Why do the source counts add up to more than 2,128?

A record can cite more than one origin. 497 of the 2,128 records cite two or more sources.

Why does a CVE scanner miss most of these packages?

Malicious packages are reported under MAL- IDs, with no CVE, no fixed version and no CVSS score, so tools that only read CVE data never see them.

Sources

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.