Safeguard
Supply Chain Security•

Malware Records List Versions, Not Packages: Why leakdeep 2.6.2 Scans Clean

Malware records usually list the exact versions someone analysed, and scanners match only those. 2026 OSV data for PyPI and npm, the leakdeep credential stealer whose version 2.6.2 isn't in its record, and a script that checks lockfiles by package name.

Safeguard Security Team
Security Research
10 min read

A malware report on a package usually lists the exact versions someone analysed. Scanners match those versions and nothing else. So when a malicious package has a version the report missed, or the publisher uploads a new one after the report, a lockfile pinned to that version scans clean. We measured how often 2026's malware records are written this way and how often it leaves versions uncovered.

This post is for teams that rely on OSV, pip-audit, npm audit or similar tools to catch malicious packages. It walks through a case from 18 October, the 2026 data for PyPI and npm, and a script that checks your lockfiles by package name, so a missing version doesn't hide a malicious package.

The short answer

In OSV's malware data, 1,432 of 1,441 PyPI records from 2026 (over 99%) and 6,102 of 12,867 npm records (47%) list specific versions rather than every version of the package. A version-matching scanner flags only those versions. On 18 October, 71 of those PyPI packages were still serving versions their record doesn't name, 24 of them with versions uploaded after the report. For npm records from September and October, the figure was 53 of 643. Some unlisted versions are legitimate, for example a real package with one hijacked release, but in a package that was malicious from the start every version is suspect. Check malware records by package name, not just by version.

One case: leakdeep

On 18 October 2026 at 01:45 UTC, OSV published MAL-2026-17921, from Amazon Inspector, for a PyPI package called leakdeep. The package describes itself as a "Professional OSINT & Credential Leak Intelligence Framework". According to Inspector's analysis, its search function is a stub that returns nothing, and every run of its command-line tool starts a background thread on Windows that copies and decrypts saved passwords, cookies and autofill data from Chrome, Edge and Brave, collects Discord tokens, takes a screenshot, and posts the lot to a webhook URL hidden in the code as an XOR-obfuscated byte array.

PyPI's own metadata shows how fast the publisher worked. All five releases went up within 29 minutes:

VersionUploaded (UTC, 18 Oct)In the malware record?
2.5.201:05Yes
2.6.001:09Yes
2.6.201:20No
2.6.401:28Yes
2.6.601:34Yes

The record lists 2.5.0, 2.5.2, 2.6.0, 2.6.4 and 2.6.6. Version 2.5.0 isn't on PyPI, and 2.6.2, which is, isn't in the record. GitHub's advisory for the same package (GHSA-4pw5-gqh4-v2pj) lists the same five versions. We asked OSV about leakdeep 2.6.2 and got nothing back, and PyPI's per-version metadata for 2.6.2 shows no vulnerabilities, while its metadata for 2.6.6 shows the malware record.

We didn't download 2.6.2, so we can't say what it contains. But it came from the same publisher, inside the same half hour, as four versions that Inspector found to be credential stealers. A scanner that matches versions treats it as clean. When we checked, about 13 hours after the report, all five versions were still downloadable.

How common is this?

We took every 2026 malware record for PyPI and npm from OSV's data dumps on 18 October and looked at how each one describes the affected versions:

PyPInpm
2026 malware records1,44112,867
Cover every version of the package96,765
List specific versions only1,4326,102

The difference follows the sources. On npm, 6,251 of the 7,689 records that include a GitHub malware advisory cover the whole package, against 514 of the 5,178 that don't. On PyPI, nearly every record lists the versions that were analysed, whatever the source.

Then, for the version-listed records, we asked each registry what it was still serving on 18 October between 15:00 and 15:05 UTC. For PyPI we checked all of 2026. For npm we checked records published in September and October.

On 18 OctoberPyPI (2026 records)npm (Sep and Oct records)
Version-listed packages checked1,432643
Still serving any version7699
Still serving a flagged version3283
Serving versions the record doesn't name7153
...of which uploaded after the report2420
Flagged versions up and new unlisted uploads since the report911

Most version-listed packages are gone entirely, which is the outcome everyone wants. The problem is concentrated in the rest. In 9 PyPI packages and 11 npm packages, the flagged versions are still up and the publisher has kept uploading: new versions arrived after the report, and no record names them.

Not every unlisted version is malicious. Some packages are legitimate projects where one release was hijacked. Two old npm packages flagged on 16 October, with releases going back to 2018, each have a single flagged version from May 2025 and another release the next day. Others are dependency confusion packages where only a high-numbered version such as 99.0.0 is flagged and an earlier 0.0.1 isn't. That's why the script below says "review" rather than "malicious" for an unlisted version. But it should never say nothing.

Check your lockfiles by package name

The fix is to ask a different question. Instead of "is this version in a malware record?", ask "does this package have a malware record at all?", then look at the versions. This script reads package-lock.json and pinned requirements.txt files, queries OSV by package name, and reports both cases. It uses only the Python standard library and sends package names and versions to OSV. It never downloads a package:

#!/usr/bin/env python3
"""Check lockfiles for packages that have an OSV malware (MAL-) record, whether or not your version is listed.

Usage: python3 mal_check.py package-lock.json requirements.txt ...
Standard library only. Sends package names and versions to api.osv.dev; downloads no packages.
"""
import json
import re
import sys
import urllib.request

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


def post(url, body):
    req = urllib.request.Request(url, json.dumps(body).encode(), {"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=60) as r:
        return json.load(r)


def get(url):
    with urllib.request.urlopen(url, timeout=60) as r:
        return json.load(r)


def read_deps(path):
    text = open(path, encoding="utf-8").read()
    if path.endswith(".json"):
        lock = json.loads(text)
        for key, meta in lock.get("packages", {}).items():
            if key.startswith("node_modules/") and "version" in meta:
                yield "npm", key.rsplit("node_modules/", 1)[1], meta["version"]
    else:
        for line in text.splitlines():
            m = re.match(r"^\s*([A-Za-z0-9._-]+)\s*==\s*([^\s;#]+)", line)
            if m:
                yield "PyPI", m.group(1).lower(), m.group(2)


def covers(affected, name, version):
    """True if this MAL record lists the version or covers every version of the package."""
    for a in affected:
        if a["package"]["name"].lower() != name.lower():
            continue
        if version in a.get("versions", []):
            return True
        for r in a.get("ranges", []):
            ev = r.get("events", [])
            if any(e.get("introduced") == "0" for e in ev) and not any("fixed" in e or "last_affected" in e for e in ev):
                return True
    return False


def main(paths):
    deps = sorted({d for p in paths for d in read_deps(p)})
    # query by package name only, so records that don't list your version still come back
    queries = [{"package": {"ecosystem": e, "name": n}} for e, n, _ in deps]
    results = post(f"{OSV}/querybatch", {"queries": queries})["results"]
    flagged = 0
    for (eco, name, ver), res in zip(deps, results):
        mal = [v["id"] for v in res.get("vulns", []) if v["id"].startswith("MAL-")]
        if not mal:
            continue
        flagged += 1
        for mid in mal:
            rec = get(f"{OSV}/vulns/{mid}")
            if covers(rec["affected"], name, ver):
                print(f"MALICIOUS  {eco} {name} {ver}: {mid} lists this version")
            else:
                listed = sorted({v for a in rec["affected"] for v in a.get("versions", [])})
                print(f"REVIEW     {eco} {name} {ver}: {mid} lists {', '.join(listed)} but not {ver}")
                print("           a version missing from a malware record isn't proof it's clean;"
                      " check whether the whole package is malicious or one release was hijacked")
    print(f"\n{len(deps)} packages checked, {flagged} with a malware record")


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

On two small fixture lockfiles, a Python service and a Node app:

$ python3 mal_check.py requirements.txt package-lock.json
REVIEW     PyPI leakdeep 2.6.2: MAL-2026-17921 lists 2.5.0, 2.5.2, 2.6.0, 2.6.4, 2.6.6 but not 2.6.2
           a version missing from a malware record isn't proof it's clean; check whether the whole package is malicious or one release was hijacked
MALICIOUS  npm itsa-react-focuscontainer 16.0.10: MAL-2026-17910 lists this version
REVIEW     npm itsa-react-maskedinput 16.1.3: MAL-2026-17911 lists 16.1.2 but not 16.1.3
           a version missing from a malware record isn't proof it's clean; check whether the whole package is malicious or one release was hijacked

6 packages checked, 3 with a malware record

The leakdeep line is the case from above: a version-matching scanner would print nothing for it. The itsa-react-maskedinput line shows why the answer is "review": its record names one May 2025 release, and 16.1.3 is a later release of a package that has existed since 2018. Someone should look at it, but it may well be clean. The itsa-react-focuscontainer lockfile entry is pinned to the flagged version itself, the case every scanner already catches.

Catching these at install time beats finding them in a lockfile. Here's Safeguard's interactive walkthrough of malicious, typosquatted and dependency confusion packages being flagged in a project:

What to do

  1. Treat a malware record on a package as a finding for every version until someone has checked. The script's "review" lines are that list.
  2. Block at install time, by name. A deny-list of package names with malware records, enforced in your proxy or registry mirror, covers new uploads that no record lists yet.
  3. If you find an unlisted version that is malicious, report it to the record's source or to OpenSSF's malicious-packages repository, which feeds OSV, so the record is extended for everyone.
  4. Don't rely on removal. Most flagged packages disappear from the registry, but not all, and not always quickly. Our malicious package quarantine guide covers what to do when one has already been installed.

The fair view

Listing versions is the honest thing for a malware analyst to do. They can vouch for what they analysed, and a record that covers every version of a hijacked legitimate project would flag years of clean releases. The registries remove most of these packages entirely, which makes the version list moot. And OSV's data is open, so a gap like 2.6.2 can be fixed by anyone who spots it.

The gap is in how tools use the data. A vulnerability record says "these versions are affected" and means the others aren't. A malware record says "we looked at these versions" and makes no claim about the rest. Treating the two the same way is what lets a version from the same publisher, uploaded in the same half hour, come back clean.

Check yourself

A malware record lists versions 1.0.0 and 1.0.2 of a package. Your lockfile pins 1.0.1. What does a version-matching scanner report?

Nothing. It matches only the listed versions. Look up the package by name, check whether the whole package is malicious or one release was hijacked, and treat 1.0.1 as suspect until you know.

Why don't analysts just mark every version of a flagged package as malicious?

Because some flagged packages are legitimate projects with one hijacked release. Marking every version would flag years of clean releases. Analysts list what they examined, and on PyPI that's almost every record.

The publisher of a flagged package uploads a new version after the report. Is it covered?

Not unless someone updates the record. In our data, 9 PyPI and 11 npm packages still served flagged versions and also had versions uploaded after the report that no record names. Block by package name to cover them.

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.