Safeguard
Supply Chain Security•

npm 0.0.1-security Explained: What 1,277 Malware Reports Show About How Long Malicious Packages Stay Up

0.0.1-security means npm removed a malicious package. Our data on 1,277 reports: newest versions stayed up a median 18.5 days. Plus a lockfile checker.

Safeguard Security Team
Security Research
11 min read

If your lockfile or npm install output shows a package at version 0.0.1-security, npm has removed that package for containing malicious code and published an empty placeholder in its place. The placeholder is harmless. What matters is whether you installed one of the earlier versions before it was replaced, and those can stay on the registry for weeks.

This post is for developers and security teams who use npm. It explains what a security holding package is and how to read the history npm keeps after a takedown. It also gives our measurements from 1,277 malicious-package reports published between 1 September and 14 October 2026, and a script that checks a lockfile for anything flagged.

The short answer

0.0.1-security is npm's security holding package: npm's security team removed the original versions for malicious code and published an empty version under its own account so nobody can republish the name. Installing the placeholder does nothing. Installing an earlier version could have compromised the machine. In our sample, the newest version of a package npm replaced this way had been on the registry for a median of 18.5 days before the takedown, and a GitHub advisory usually appeared within the same hour. Treat any hit as a possible compromise, find out which version you installed and when, and rotate secrets from a clean machine.

What a security holding package is

When npm takes down a malicious package, it removes every version and publishes 0.0.1-security from the npm account. The placeholder's package.json has the description "security holding package" and the repository npm/security-holder. Its README says the package was "removed from the registry by the npm security team". It contains no code.

The placeholder exists so the name can't be claimed again. Without it, an attacker could republish a removed name, and every lockfile or install script that still refers to it would pull the new upload.

The registry document keeps more than the placeholder. Its time field still lists every removed version with its publish timestamp, along with 0.0.1-security and, in recent takedowns, a 0.0.0-stage entry from a minute or two earlier. That history is what lets you work out whether an install in your logs fell inside the dangerous window:

curl -s https://registry.npmjs.org/opencode2 | jq '.time'

One takedown in detail: opencode2

On 13 October GitHub published GHSA-8w65-j2wh-mqv7, "Malware in opencode2", along with eight sibling advisories. The names copy the layout of OpenCode, an open source coding agent that ships on npm as opencode-ai plus per-platform binary packages such as opencode-darwin-arm64. The malicious set was opencode2 and opencode2-<platform> for macOS, Linux (glibc and musl) and Windows, on x64 and arm64.

The registry history shows how long they were up:

PackageVersions publishedFirst versionLast versionReplaced by holding package
opencode26525 Sep 19:2113 Oct 12:4913 Oct 17:46
opencode2-linux-x646525 Sep 19:2113 Oct 12:4813 Oct 17:45
Seven other platform packages63 each27 Sep 08:00 to 08:0113 Oct 12:45 to 12:5013 Oct 17:46 to 17:47

Times are UTC. For 18 days the publisher pushed new versions, often several a day. The GitHub advisory carries no technical detail beyond its standard text: any computer that installed the package "should be considered fully compromised". We didn't analyse the payload, since the removed tarballs are no longer served.

What 1,277 reports say about takedown timing

We took every malicious-package record (OSV IDs beginning MAL-) for npm published between 1 September and 14 October 2026 in the OSV npm export. OSV merges reports from several sources: Amazon Inspector, GitHub's malware advisories, the OpenSSF package analysis project and ReversingLabs. On 15 October we fetched each package's registry document and classified what had happened to it.

What the registry showed on 15 OctoberPackages
Replaced by a 0.0.1-security holding package581
Unpublished, with an unpublished entry in the history411
No registry document at all (HTTP 404)141
Package still listed, flagged versions removed20
Flagged versions still downloadable120
Other4

Two very different patterns came out of the timing.

Unpublished packages disappeared fast. For the 411 unpublished packages, the median gap between the first version and the unpublish was about five hours, and 80% were gone within a day. The registry doesn't record who unpublished them, so some were likely removed by their own publishers.

Packages replaced by holding packages stayed up much longer. For the 579 of them with version history:

Time on the registry before the holding packageMedianWithin a dayWithin a weekMore than 30 days
Since the first version30.3 days11%25%50%
Since the newest version18.5 days19%34%41%

The first-version figure overstates how long malware was live, because some of these packages were published years ago and only flagged now. Eleven per cent had a first version more than a year before the takedown. Those could have been harmless at first and turned malicious later, and the history alone can't tell you which. The newest-version figure is the safer reading: the latest release of the package, which is what a fresh install pulls, was downloadable for a median of 18.5 days.

The public report usually arrives with the takedown, not before it. For 48% of the 990 removed packages, the first malicious-package record appeared within an hour of the takedown. GitHub's malware advisories, which npm audit reads, are typically published at the moment npm removes the package. They tell you what to look for afterwards. They aren't an early warning.

There were exceptions. For 121 packages, a public report existed more than a day before the takedown, with a median gap of 7.4 days and a maximum of 39. Thirty-one of those packages had new versions published after the report. One example is tailwindcss-form. Its first version appeared on 16 September, Amazon Inspector reported it to OSV on 18 September, the publisher added another version on 10 October, and npm replaced it on 15 October.

120 flagged packages are still downloadable

The 120 packages whose flagged versions were still on the registry on 15 October account for 353 versions. Eighty-nine had been reported a week or more earlier, and 22 before 15 September. We confirmed with HEAD requests that tarballs for three of them still returned HTTP 200. We didn't download or run any of them, and we aren't naming them here because some reports in an automated feed may be false positives.

The good news is that npm audit knows about almost all of them. We sent every still-downloadable flagged version to the registry's bulk advisory endpoint, which is what npm audit queries. All 120 packages had an advisory. In one package flagged as malicious in full, 58 of 84 versions fell outside the version ranges in GitHub's advisory, so npm audit would pass those 58. Apart from that package and some 0.0.0-stage placeholders, every flagged version we sent matched an advisory.

So for this set, npm audit does its job, as long as you run it.

Check a lockfile for flagged packages

This script reads package-lock.json (formats 1, 2 and 3) and asks OSV which of its name@version pairs have a MAL- record. It also flags any 0.0.1-security entry, then reports what the registry shows for each hit. It reads the lockfile and calls public APIs. It never installs or downloads a package, and it uses only the standard library.

#!/usr/bin/env python3
"""Check an npm lockfile for packages flagged as malicious.

Reads package-lock.json (v1, v2 or v3), asks OSV which name@version pairs
have a malicious-package (MAL-) record, flags any npm security holding
version (0.0.1-security), and reports what the registry shows for each hit.
Reads only the lockfile and public APIs. Never installs anything.
"""
import json, sys, urllib.request, urllib.parse

def lock_entries(path):
    lock = json.load(open(path))
    seen = set()
    for key, meta in (lock.get("packages") or {}).items():          # v2 / v3
        if not key or "version" not in meta or meta.get("link"):
            continue
        name = meta.get("name") or key.rsplit("node_modules/", 1)[-1]
        seen.add((name, meta["version"]))
    def walk(deps):                                                 # v1
        for name, meta in (deps or {}).items():
            if "version" in meta:
                seen.add((name, meta["version"]))
            walk(meta.get("dependencies"))
    if not lock.get("packages"):
        walk(lock.get("dependencies"))
    return sorted(seen)

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

def osv_malicious(entries):
    hits = {}
    for i in range(0, len(entries), 1000):
        chunk = entries[i:i + 1000]
        res = post("https://api.osv.dev/v1/querybatch", {"queries": [
            {"package": {"name": n, "ecosystem": "npm"}, "version": v} for n, v in chunk]})
        for (n, v), r in zip(chunk, res["results"]):
            ids = [x["id"] for x in r.get("vulns", []) if x["id"].startswith("MAL-")]
            if ids:
                hits[(n, v)] = ids
    return hits

def registry_status(name, version):
    url = "https://registry.npmjs.org/" + urllib.parse.quote(name, safe="@")
    try:
        doc = json.load(urllib.request.urlopen(url, timeout=30))
    except urllib.error.HTTPError as e:
        return f"registry HTTP {e.code}"
    t = doc.get("time", {})
    published = (t.get(version) or "?")[:16]
    if "0.0.1-security" in doc.get("versions", {}):
        return f"published {published}, replaced by npm holding package {t['0.0.1-security'][:16]}"
    if "unpublished" in t:
        return f"published {published}, unpublished {t['unpublished']['time'][:16]}"
    if version in doc.get("versions", {}):
        return f"published {published}, STILL DOWNLOADABLE"
    return f"published {published}, version removed"

def main(path):
    entries = lock_entries(path)
    hits = osv_malicious(entries)
    for n, v in entries:
        if v == "0.0.1-security":
            hits.setdefault((n, v), []).append("npm security holding package")
    print(f"{len(entries)} packages checked, {len(hits)} flagged")
    for (n, v), ids in sorted(hits.items()):
        print(f"  {n}@{v}: {', '.join(ids)}")
        print(f"      {registry_status(n, v)} UTC")
    return 1 if hits else 0

if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "package-lock.json"))

We ran it on a fixture lockfile with lodash, two of the opencode2 packages and a holding-package entry for tailwindcss-form:

4 packages checked, 3 flagged
  opencode2@2.7.53: MAL-2026-17852
      published 2026-10-13T12:49, replaced by npm holding package 2026-10-13T17:46 UTC
  opencode2-darwin-arm64@2.7.53: MAL-2026-17853
      published 2026-10-13T12:45, replaced by npm holding package 2026-10-13T17:46 UTC
  tailwindcss-form@0.0.1-security: MAL-2026-16282, npm security holding package
      published 2026-10-15T07:43, replaced by npm holding package 2026-10-15T07:43 UTC

It exits with status 1 when anything is flagged, so it can fail a CI job. OSV merges all the sources in our sample, including reports made before a takedown, so it can flag a package before GitHub publishes its own advisory, as it did with tailwindcss-form. Run it on old lockfiles too, from release tags or past CI artifacts, because the question that matters is what you installed at the time, not what the lockfile says today.

If you find one

  1. Work out the window. Compare the version and its publish time from the registry history with your CI logs and lockfile history. A 0.0.1-security entry on its own means someone installed the placeholder after the takedown. Check whether an earlier lockfile pinned a real version.
  2. Assume compromise if a real version was installed. Follow the advisory: rotate every secret the machine or CI runner could reach, from a different machine, and rebuild the environment rather than cleaning it.
  3. Find the path in. Run npm ls <name> --package-lock-only against the old lockfile to see whether it was a direct dependency or came in through another package. A transitive hit means another package you trust depended on it.
  4. Block the name. Add it to your registry proxy's blocklist so a mistyped install can't pull a lookalike. Our quarantine procedures post covers this step.

The fair view

Most packages in this sample were never a real threat to most teams. Many are spam, test uploads or research proof-of-concepts with few downloads, and OSV recorded between a few hundred and a few thousand npm reports a month in 2026. The registry history shows when versions were published, not when they turned malicious or how many people installed them. And npm audit did match nearly every flagged version still on the registry.

The timing is still the useful lesson. The advisory you'd act on usually lands at the takedown, after a package has been installable for days or weeks, and sometimes after earlier public reports went unactioned. Checking lockfiles against all the malicious-package sources, including old lockfiles, closes some of that gap. Safeguard's Gold directory also lists known malicious packages if you want to look one up by name.

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.