Safeguard
Supply Chain Security•

106 malicious npm and PyPI packages as of 28 Oct 2026: what they did

As of 28 Oct 2026, 23:10 UTC, per GitHub's advisory database: 106 malicious npm and PyPI packages published 26 to 28 October. What they did, a lockfile check, cleanup steps and install-time blocks.

Safeguard Security Team
37 min read

Between 26 and 28 October 2026, GitHub's advisory database published 112 malware advisories. Together they cover 106 distinct packages: 96 on npm and 10 on PyPI, spread over 172 affected version entries (156 npm, 16 PyPI). That is the count as of 28 October 2026, 23:10 UTC per GitHub's advisory database, taken from the public advisories API with the filter type=malware and published=2026-10-26..2026-10-28. If you only need the short version: check your lockfiles and build caches against the list with the script in this post, treat any machine that installed a listed version as compromised, rotate its secrets from a different machine, and put an install-time control in front of npm and pip so the next batch is refused before it runs.

Most of these packages never reached many machines. Several were removed by the registry within minutes. Some were placeholders or self-described "tests". A handful were plainly built to hurt people: an install-time Monero miner that persists through systemd, a dependency-confusion beacon pointed at internal package scopes, an npm package that tries to publish new packages under whatever npm identity installs it, and three PyPI packages that hide an infostealer inside an image and look up their command server from blockchain transaction history. The cost of finding out which group you are in is one script run. The cost of not checking is a developer laptop or a CI runner that quietly belongs to someone else.

This post walks through what was published and when, what each family did according to the advisory and OpenSSF records (and nothing beyond them), how to check your own estate, how to clean up, and which controls would have stopped these installs.

What was published, and when

GitHub's advisory database carries a separate advisory type for malware. Most entries are imported from the OpenSSF malicious-packages repository, which assigns each report an identifier in the form MAL-YYYY-NNNNN and stores it as an OSV record. GitHub then republishes the record with a GHSA identifier and the affected versions. Records come from several reporters, and each record says which one in a "Source" line: the OpenSSF Package Analysis project, an independent researcher who runs a public bad-packages feed for PyPI, and a cloud provider's inspection service, among others. Where a record has a detailed write-up, it is that reporter's text.

Here is the batch by GitHub publication date (UTC):

Advisory publication date (UTC)Advisories
26 October 202665
27 October 202627
28 October 202620
Total112

And by ecosystem:

EcosystemDistinct packagesAffected version entries
npm96156
PyPI1016
Total106172

There are three honest ways to count a batch like this, and they give three different numbers. Counting advisories gives 112. Counting packages gives 106, because six packages appear in two advisories each (for example dsh-plugin-blender-3d was covered once on 26 October and again on 28 October, and four of the @greenlake and @sxm-web packages have two records). Counting affected version entries gives 172, because one advisory can list many versions: @iobandamwer/cache-helper lists 13, and @zaip-ai/cli and orderly-plugin-liquidity-heatmap list 7 each. When you see a headline number for a batch like this, ask which of the three it is. For checking your own systems, the version entries are what matter, and the script later in this post uses them directly.

Two dates are easy to confuse. The advisory publication date is when GitHub published the record. The package upload date is when the package went onto npm or PyPI, and that can be earlier. The records for the @greenlake, @sxm-web, @drone-js-ext and @ppa-web-components packages say the npm account behind them published 14 such packages "between 2026-10-23 21:23 and 2026-10-25 14:28 UTC", so those uploads predate the advisory window by up to three days. If you are searching install logs, search from 23 October, not 26 October.

What the packages did, according to the records

GitHub's malware advisories come in two shapes. Some carry a detailed write-up from the reporter: install hooks, endpoints, files touched. Most carry only GitHub's standard text, which reads in full: "Any computer that has this package installed or running should be considered fully compromised. All secrets and keys stored on that computer should be rotated immediately from a different computer. The package should be removed, but as full control of the computer may have been given to an outside entity, there is no guarantee that removing the package will remove all malicious software resulting from installing it." In this batch, 89 of the 112 advisories carry only that standard text. For those, we describe nothing beyond it, because the public record says nothing beyond it.

The families below are grouped by what the records describe. Quotes are from the advisory or OSV record text.

1. Dependency-confusion beacons aimed at internal scopes

Dependency confusion works because many build systems will happily resolve a package name from the public registry if a private package of the same name is not pinned to a private registry. An attacker who guesses an organization's internal package scope can publish a higher version number on the public registry and wait for a build to pick it up.

Two clusters in this batch follow that pattern.

The first is a set of 14 packages from one npm account under the @greenlake, @sxm-web, @drone-js-ext and @ppa-web-components scopes. The records for @greenlake/auth, @greenlake/context-pane, @greenlake/mfe-login-url and @sxm-web/client-sdk-core describe the behaviour the same way. Each package "is described as a 'Compatibility shim', uses inflated version numbers, and its README calls it an 'authorized dependency-confusion test'". Each listed version "has a postinstall script that runs node beacon.cjs on npm install, and index.js calls the same code when the package is imported; it POSTs the hostname, install path and current working directory over plain HTTP to hxxp://185.158.107[.]175:8787/_ah/dc, and index.js exports a Proxy that returns no-op functions so builds importing the real package keep running."

The second cluster is a single package, @webapp-shared/login-vmok. Its record says it "uses the @webapp-shared scope, which matches a private internal package scope, and was published on 2026-10-27 in versions 1.0.0, 1.0.1, 1.1.0, 2.0.1, 3.0.0 and 99.0.1 so that builds resolving the internal name from the public registry install it instead." Its postinstall script "runs node beacon.cjs, which sends an HTTP POST over cleartext HTTP to the hardcoded IP endpoint http://13.140.137.222:8787/_ah/dc with the package name, the machine's hostname, the install directory, the current working directory and the Node.js version". The record adds that "the beacon code is the same as in other dependency-confusion packages sending to hxxp://185.158.107[.]175:8787/_ah/dc (e.g. MAL-2026-18276), with only the endpoint and package name changed", and that "the inert 0.0.0-stage and 0.1.0 placeholder versions carry no install script and are not affected." Its remediation advice is specific: "remove the package, pin or scope internal package names to the internal registry, and block 13.140.137.222 on build hosts."

2. An install-time Monero miner with persistence

Two npm packages, dsh-plugin-blender-3d@0.2.1 and @walknpm/todo-cli@2.0.1, shipped the same miner. The records describe dsh-plugin-blender-3d@0.2.1 as "a new release of a DeepSeek Harness plugin for Blender 3D modeling whose only change from 0.2.0 is a postinstall script, sh scripts/miner.sh, that runs on npm install". For @walknpm/todo-cli@2.0.1 the description is the same, for "a todo-list CLI and web app".

What the script does, per the records:

  • It "downloads a gzip carrier from amplify-gursha-dev-121835-deployment.s3.amazonaws.com to /tmp/.smh".
  • "as root or with passwordless sudo it runs the carrier's installer (sh /tmp/.smi agent), which installs an XMRig miner plus two helper daemons under random system-component names and persists them as systemd units or @reboot cron entries".
  • "otherwise it extracts the bundled XMRig binary to ~/.local/lib/<random name>/ and mines Monero to networkservicesupp.flashhub.net:8443 over TLS with a hardcoded wallet."

The records add that the npm account "published the same scripts/miner.sh in dsh-plugin-blender-3d@0.2.1 within 27 seconds" of the other version on 26 October, and that the script "is byte-identical to the one in @sudobility/testomniac_client (MAL-2026-18129)". The OpenSSF Package Analysis project separately flagged both, saying "the package executes one or more commands associated with malicious behavior."

Two things stand out. First, the malicious release is a point release of a package that previously looked legitimate, and the only change is the install script. Anyone running a caret range (^0.2.0 or ^2.0.0) would have picked it up on the next fresh install. Second, the persistence step depends on privilege. CI runners that run as root, or containers where the build user has passwordless sudo, give the installer the systemd route. Removing the package from node_modules does nothing about a systemd unit with a random name.

3. An npm package that publishes more npm packages

The publish-poc-test3 record (seven versions between 1.0.15 and 1.0.27), credited to a cloud provider's inspection service, describes what it calls "characteristic registry self-propagation / worm behavior". In the record's words, "the package's postinstall lifecycle script creates a temporary directory, writes a fabricated package.json whose name is publish-poc-found-<random-hex> (random suffix derived via 'openssl rand -hex 2'), and then invokes 'npm publish' from that directory."

The detail worth reading twice is how it hides the command: "The literal string 'npm publish' is reconstructed at runtime by concatenating three fragments ('np', 'm pu', 'blish') through printf, specifically to prevent manifest and string scanners from matching the command." And the impact: "The publish runs against the public npm registry using whatever npm authentication context the installing environment currently has (an _authToken from ~/.npmrc, NPM_TOKEN / NODE_AUTH_TOKEN in the environment, or an active CI publish identity), so running 'npm install' of this package on a developer workstation or CI host that holds publish rights causes a new attacker-named package to be published under the installer's npm identity."

A related package, publish-poc-base@1.0.7, is described differently. Its postinstall "reads the first line of ../../README.md from the installer's parent workspace", stages a package in a temp directory, and "writes an .npmrc containing a hardcoded npm registry _authToken". The record says "the staged directory is removed with rm -rf before any publish or network call fires, so this version does not itself execute the publish step", but that "the shipped token is distributed in cleartext to every installer and the staging logic is a working template for auto-publishing packages under the token owner's identity using data harvested from the installer's workspace."

The names say "poc". The records treat them as malicious anyway, and the reasoning is sound: a proof of concept that publishes under your identity is indistinguishable, from your side, from an attack that does the same. If you hold npm publish rights on a machine that installed publish-poc-test3, check your npm account for packages named publish-poc-found- followed by four hex characters.

4. A native addon that checks for sandboxes before it runs

@testsecuiitytestme/cache-helper (MAL-2026-18358) is the most carefully built package in the batch, and its record is the longest. It "is the only package of the npm account testsecuiitytestme. It was created on 2026-10-27 at 12:08 UTC with npm's staged-publish placeholder 0.0.0-stage, followed by 1.0.1 (12:28 UTC), 1.0.2 (15:33 UTC) and 1.0.3 (16:24 UTC)". It "describes itself as 'Cache helper utilities' and has no README, repository or source code."

Per the record, "each version runs a native addon at install time." Version 1.0.1 loads a prebuilt .node file from a postinstall script. Versions 1.0.2 and 1.0.3 "depend on node-gyp-build and run it as postinstall, which loads the prebuilt addon shipped in prebuilds/linux-x64/node.napi.node or prebuilds/win32-x64/node.napi.node." Version 1.0.3 "adds a binding.gyp with an empty sources list and an install script node-gyp rebuild, so the package looks like an ordinary native module; there is no C source in any version."

The addon, for Linux and Windows, "is a loader. It checks for CI and sandbox environments (GITHUB_ACTIONS, GITLAB_CI, TRAVIS, CIRCLECI, JENKINS_URL, BUILDKITE, CODEBUILD_BUILD_ID and others, plus SANDBOX), containers (/proc/1/cgroup: docker, containerd, kubepods, podman), a debugger (TracerPid), memory and CPU count, then reads the sibling file .napi_cache, writes it to a hidden temp file and runs it in a detached process."

The rest of the record is the most alarming text in the batch. The cache file "is JavaScript encoded with a single-byte XOR (0x96). It exits under CI, decodes a second layer (repeating 32-byte XOR, then brotli), locates the Bun runtime or downloads bun 1.2.18 from the official oven-sh/bun GitHub releases, writes the decoded program to the temp directory and runs it detached with Bun." The reporter adds: "This loader shape matches the second wave of the Shai-Hulud npm worm."

According to the record, the strings in the final program "show credential collection for npm, GitHub, GitLab, PyPI, RubyGems, JFrog, Docker Hub and GHCR, AWS, GCP, Azure, HashiCorp Vault and Kubernetes, plus .ssh, .git-credentials, .env files, browser login databases, the macOS keychain, 1Password and Bitwarden. They also show propagation: npm publishing with provenance, PyPI and RubyGems publishing, GitHub workflow and composite-action injection, GitLab CI template changes, and writes to AI assistant configuration (.claude/settings.json hooks, .cursor rules, .mcp.json, copilot-instructions.md, CLAUDE.md)." Two caveats from the same record: "The exfiltration endpoints sit behind a further string-encoding layer that was not decoded. Analysis was static only; nothing was executed." So the list above is what the code contains, not a confirmed record of what it sent anywhere.

The writes to AI assistant configuration deserve a second look if you use coding assistants. A hook added to an assistant's settings file, or a new instruction file in a repository, runs or steers the assistant the next time someone opens the project. If this package was ever installed on a machine, check those files in every repository on it, not just the one that pulled the package in.

5. PyPI infostealers, a remote access tool and a reverse shell

All ten PyPI packages carry detailed records, each credited to the independent researcher's bad-packages feed. They group into four behaviours.

Steganographic infostealer with blockchain C2. claudeopusfile (0.1.1 and 0.1.2), claudeopuseni (0.1.0 and 0.1.1) and claudeopusenifile (0.1.3) share one campaign, which the records label 2026-09-donutautosellsrc. Each record says: "During installation, obfuscated code is used to fetch code hidden in an image containing a Python application with a native extension module. This module holds an obfuscated infostealer which collects sensitive data and exfiltrates them to a C2 server retrieved from transaction history in the Polygon blockchain." The listed reasons include "infostealer", "Downloads and executes a remote executable", "obfuscation", "The package contains code to detect if it is running in a sandbox environment", "steganography" and "c2-in-blockchain". The records reference one Polygon address and one file hash, both listed in the technical details section below. The names lean on a well known AI model brand, which is a lure aimed at developers trying new AI tooling. Nothing in the records connects these packages to the company whose model name they borrow.

Credential and wallet theft. dictmerge-lite 1.0.0 is described plainly: "During installation or import, the package executes infostealer code and exfiltrates cryptocurrency wallet, browsers' data, SSH keys and development credential files." Its reasons list "exfiltration-crypto", "exfiltration-browser-data", "exfiltration-ssh-keys" and "exfiltration-credentials", and note that "the package overrides the install command in setup.py to execute malicious code during installation."

trajmetrics 0.5.3 takes files: "When starting as a module, the package scans the whole system and exfiltrates selected files by type. If following the README instruction, it starts a background job constantly monitoring the system and exfiltrating files as soon as they change." Its reasons are "native-extension" and "files-exfiltration".

Remote command execution. numprint 1.2.0: "During installation the package starts a background process executing commands provided by the attacker. The C2 communication happens through a GitHub Gist." The record classes it as a remote access tool ("rat") that "overrides the install command in setup.py". h13t0ry-hello-demo 0.1.0 is shorter still: "During import package starts a reverse shell".

6. Install beacons and single-signal flags on npm

csvflow-schema 1.0.0 is described as a "Lightweight CSV column-schema validation helper" that "adds a postinstall script that runs node ./postinstall.js on npm install." Per the record, the script "writes the username, hostname, working directory, parent process id and install path to csvflow_schema/pi_2c75177b.ok in the system temp directory, starts calc.exe through cmd /c start, and sends an HTTPS GET to https://cdn-status.mulderitsme.net/pi/2c75177b, a fixed-id install beacon." The calc.exe launch is a classic proof-of-execution marker. It was removed 17 minutes after upload, per the record.

7. Families with only the standard advisory text

The remaining 89 advisories carry GitHub's standard text and no behaviour detail. Several of them form recognisable groups by name and publication time. We list them so you can search for them, and we make no claim about what they did beyond the advisory's own statement that a machine that installed one "should be considered fully compromised".

  • camelotlabs: camelotlabs-config, camelotlabs-core, camelotlabs-sdk, camelotlabs-utils and camelotlabs-worker, all published as advisories at 14:33 UTC on 28 October, all versions affected.
  • Crypto and form toolkits with spelling variants: ccsec-crypto-toolkit, ccsec-form-validator, csec-crypto-toolkit, csec-form-validator, csec-form-validatorr, ccsseecc-crypto-toolkit and ccsseecc-form-validator, all published at 23:04 UTC on 27 October. The doubled letters and repeated syllables are the shape of a name family registered to catch typing mistakes or to survive takedowns.
  • Probe-style scoped set: ten packages under @s4yhiitestpoc, with names such as r2-dns, r2-https-b, scan-control-a and scan-https-dualuse-e, all at 00:13 UTC on 26 October.
  • AI and agent themed names: @atlisp/mcp@1.12.14, @zaip-ai/cli (seven versions from 6.4.0 to 7.4.0), agentenact@0.2.0 and ctf-agent@0.1.0. The npm @atlisp/mcp name suggests a Model Context Protocol server, which is software that AI coding assistants install and run with the developer's privileges.

Technical details

This section collects the indicators that appear in the advisory and OSV records for this batch. Nothing here is inferred. Where a record gives no indicator, there is none listed.

Network indicators named in the records

IndicatorTypePackages (per record)Record
hxxp://185.158.107[.]175:8787/_ah/dcCleartext HTTP POST endpoint@greenlake/*, @sxm-web/client-sdk-core, and the account's other dependency-confusion packagesMAL-2026-18316, 18317, 18318, 18319
http://13.140.137.222:8787/_ah/dcCleartext HTTP POST endpoint@webapp-shared/login-vmokMAL-2026-18356
amplify-gursha-dev-121835-deployment.s3.amazonaws.comPayload download hostdsh-plugin-blender-3d@0.2.1, @walknpm/todo-cli@2.0.1MAL-2026-18321, 18320
networkservicesupp.flashhub.net:8443Mining pool over TLSsame two packagesMAL-2026-18321, 18320
https://cdn-status.mulderitsme.net/pi/2c75177bInstall beacon (HTTPS GET)csvflow-schema@1.0.0MAL-2026-18357
Polygon address 0x9c0a507300fd902787bb193d80fca5ce6e1bff9aBlockchain lookup for C2claudeopusfile, claudeopuseni, claudeopusenifileMAL-2026-18298, 18299, 18300
A GitHub Gist (not named in the record)C2 channelnumprint@1.2.0MAL-2026-18343

Host indicators named in the records

IndicatorPackages
/tmp/.smh, /tmp/.smidsh-plugin-blender-3d@0.2.1, @walknpm/todo-cli@2.0.1
XMRig under ~/.local/lib/<random name>/same
New systemd units or @reboot cron entries with random system-component namessame, when installed as root or with passwordless sudo
A hidden temp file written from .napi_cache, run detached, possibly with a downloaded Bun 1.2.18 runtime@testsecuiitytestme/cache-helper 1.0.1 to 1.0.3
Unexpected changes to .claude/settings.json hooks, .cursor rules, .mcp.json, copilot-instructions.md, CLAUDE.md, or GitHub workflow filesstrings listed in the @testsecuiitytestme/cache-helper record

The @testsecuiitytestme/cache-helper record (MAL-2026-18358) lists SHA-256 hashes for tarballs of versions 1.0.1, 1.0.2 and 1.0.3 and for the two .node prebuilds and the .napi_cache file in 1.0.2 and 1.0.3. Read them from the record itself so you match on the exact values.

Install mechanisms

Every npm record in this batch that describes a mechanism names an npm lifecycle script, almost always postinstall (one also uses install), and two clusters run the same code again on require() or import. The PyPI records describe code that runs during installation, often by overriding "the install command in setup.py", or at import. That is the single most useful fact in this batch. Turning off install scripts by default, and building PyPI dependencies from wheels rather than running setup.py, removes the main execution path for most of what is described here. It does not remove the import-time path, which is why you still need to stop the package from arriving at all.

Finding records

Each package has an OSV identifier of the form MAL-2026-18xxx and a GitHub advisory id. Use the advisory query linked under Sources to look up any package by name.

How to check whether you installed any of them

There are three places a malicious package can leave a trace: your lockfiles (what your projects resolve to), your installed trees and caches (what actually landed on disk), and your logs (what CI fetched). Check all three. A lockfile check alone misses the developer who ran a one-off npx or pip install outside any project.

Step 1: pull the list and check every lockfile in a directory tree

The script below uses only the Python standard library. It downloads the 26 to 28 October malware advisories from GitHub's public API (no token needed for one run), then walks a directory tree and checks package-lock.json, npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml, requirements*.txt, poetry.lock and uv.lock files against the affected names and versions. An advisory that says all versions are affected matches any version. It prints each hit with the file, package, version and advisory id, and exits with status 1 if it finds anything, so you can drop it into CI.

#!/usr/bin/env python3
# check_mal_oct2026.py: check lockfiles against GitHub malware advisories
# published 26 to 28 Oct 2026. Usage: python3 check_mal_oct2026.py <dir>
import json, os, re, sys, urllib.request

API = ("https://api.github.com/advisories?type=malware"
       "&published=2026-10-26..2026-10-28&per_page=100")

def fetch():
    out, url = [], API
    while url:
        req = urllib.request.Request(url, headers={
            "Accept": "application/vnd.github+json", "User-Agent": "mal-check"})
        with urllib.request.urlopen(req, timeout=60) as r:
            out += json.load(r)
            m = re.search(r'<([^>]+)>; rel="next"', r.headers.get("Link") or "")
            url = m.group(1) if m else None
    bad = {}
    for a in out:
        for v in a["vulnerabilities"]:
            eco = "npm" if v["package"]["ecosystem"] == "npm" else "pypi"
            name = v["package"]["name"]
            if eco == "pypi":
                name = re.sub(r"[-_.]+", "-", name).lower()
            rng = (v.get("vulnerable_version_range") or ">= 0").strip()
            ver = "*" if rng.startswith(">=") else rng.lstrip("= ").strip()
            bad.setdefault((eco, name), {}).setdefault(ver, a["ghsa_id"])
    return bad

def hit(bad, eco, name, ver):
    if eco == "pypi":
        name = re.sub(r"[-_.]+", "-", name).lower()
    vers = bad.get((eco, name))
    if not vers:
        return None
    return vers.get("*") or vers.get(ver)

def npm_lock(path):
    d = json.load(open(path))
    for k, v in (d.get("packages") or {}).items():
        if "node_modules/" in k and isinstance(v, dict):
            yield k.split("node_modules/")[-1], v.get("version", "")
    def walk(deps):
        for n, v in (deps or {}).items():
            yield n, v.get("version", "")
            yield from walk(v.get("dependencies"))
    yield from walk(d.get("dependencies"))

def text_pairs(path, eco):
    t = open(path, errors="replace").read()
    base = os.path.basename(path)
    if base == "yarn.lock":
        for m in re.finditer(r'^"?(@?[^@\s"]+)@[^\n]*\n\s+version:? "?([^"\s]+)', t, re.M):
            yield m.group(1), m.group(2)
    elif base == "pnpm-lock.yaml":
        for m in re.finditer(r"^\s+'?/?(@?[^@\s:'(]+)@([0-9][^\s:'(]*)", t, re.M):
            yield m.group(1), m.group(2)
    elif base in ("poetry.lock", "uv.lock"):
        for m in re.finditer(r'name = "([^"]+)"\s*\nversion = "([^"]+)"', t):
            yield m.group(1), m.group(2)
    else:
        for m in re.finditer(r"^\s*([A-Za-z0-9_.\-]+)\s*==\s*([^\s;#\\]+)", t, re.M):
            yield m.group(1), m.group(2)

def main(root):
    bad = fetch()
    print(f"loaded {len(bad)} packages from GitHub malware advisories", file=sys.stderr)
    found = 0
    for dp, dn, fn in os.walk(root):
        dn[:] = [d for d in dn if d not in (".git", "node_modules", ".venv", "venv")]
        for f in fn:
            p = os.path.join(dp, f)
            try:
                if f in ("package-lock.json", "npm-shrinkwrap.json"):
                    pairs, eco = npm_lock(p), "npm"
                elif f in ("yarn.lock", "pnpm-lock.yaml"):
                    pairs, eco = text_pairs(p, "npm"), "npm"
                elif f in ("poetry.lock", "uv.lock") or re.match(r"requirements.*\.txt$", f):
                    pairs, eco = text_pairs(p, "pypi"), "pypi"
                else:
                    continue
                for name, ver in pairs:
                    g = hit(bad, eco, name, ver)
                    if g:
                        found += 1
                        print(f"HIT {p}: {eco} {name}@{ver} ({g})")
            except Exception as e:
                print(f"skip {p}: {e}", file=sys.stderr)
    print(f"{found} hit(s)", file=sys.stderr)
    sys.exit(1 if found else 0)

if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else ".")

Run it against the folder that holds your checkouts:

python3 check_mal_oct2026.py ~/src

The script deliberately skips node_modules and virtual environments while walking for lockfiles, because the lockfile is the record of what resolved. Step 2 covers what is on disk.

If you prefer to query one package at a time, OSV's API takes a package and version and returns any records, including MAL records:

curl -s https://api.osv.dev/v1/query \
  -d '{"package":{"ecosystem":"npm","name":"black-spindle"},"version":"999.0.0"}'

The same call with "ecosystem":"PyPI" works for Python packages. The OpenSSF malicious-packages repository also keeps every record as a file under osv/malicious/<ecosystem>/<package>/, so a shallow clone of that repository gives you an offline copy you can grep.

Step 2: check installed trees, global installs and caches

For npm, npm ls answers the question for one project and its installed tree:

npm ls black-spindle @walknpm/todo-cli dsh-plugin-blender-3d csvflow-schema --all
npm ls -g --depth=0

Global installs matter here. Several names in this batch (@zaip-ai/cli, ctf-agent, @atlisp/mcp, @walknpm/todo-cli) read like command-line tools or MCP servers that a developer would install globally or run through npx. npx keeps its downloads in the npm cache under _npx, so also look there:

ls "$(npm config get cache)/_npx" 2>/dev/null
grep -rl --include=package.json -E '"name": "(black-spindle|@zaip-ai/cli|ctf-agent|@atlisp/mcp)"' \
  "$(npm config get cache)/_npx" 2>/dev/null

For Python, check each environment you use, not just the system one:

pip show claudeopusfile claudeopuseni claudeopusenifile dictmerge-lite \
  trajmetrics numprint h13t0ry-hello-demo netweavex zagents-framework changes-lxc-wrapper
pip list --format=freeze | grep -iE '^(claudeopus|dictmerge-lite|trajmetrics|numprint|h13t0ry|netweavex|zagents|changes-lxc)'

pip show prints a warning for each name that is not installed and full metadata for any that is. Run it inside every virtual environment, conda environment and container image you build from.

Step 3: check CI and proxy logs from 23 October onward

If your builds fetch through a repository manager or an egress proxy, search its request logs for the package names in the advisories and for the network indicators in the technical details table. Start from 23 October, not 26 October, because some uploads predate their advisories. For CI systems that keep job logs, a search for postinstall lines that mention beacon.cjs, miner.sh or postinstall.js alongside an unfamiliar package name is a quick second net.

If you find one: cleanup

The advisories set the bar, and it is high: any computer that installed a listed version "should be considered fully compromised", and "all secrets and keys stored on that computer should be rotated immediately from a different computer." Here is what that means in practice, in order.

  1. Isolate the machine. Take the laptop off the network or stop the runner. If it is a CI runner, disable the runner registration so no new jobs land on it.
  2. Rotate from a clean machine. Everything that was reachable from the affected machine is in scope: npm and PyPI tokens, cloud provider keys, Git hosting tokens and SSH keys, CI secrets injected into the job, database credentials in .env files, and browser sessions for anything the developer was signed into. For dictmerge-lite the record specifically lists cryptocurrency wallets, browser data, SSH keys and "development credential files". Rotate first, investigate second; the rotation is what ends the attacker's access.
  3. Check what the record says the package left behind. For the miner packages, look for systemd units and @reboot cron entries you did not create, unexplained binaries under ~/.local/lib/, and the /tmp/.smh and /tmp/.smi files. For publish-poc-test3, check your npm account for packages you did not publish. For csvflow-schema, the temp-directory marker file. Removing the package is not the same as removing its effects, and the advisories say so.
  4. Block the indicators. The @webapp-shared/login-vmok record asks for exactly this: "block 13.140.137.222 on build hosts". Do the same for the other endpoints in the table.
  5. Rebuild, do not clean. For CI runners and containers, rebuild from a known good image. For developer laptops, follow your incident response policy; with a persistence mechanism in play, a reimage is the safe default.
  6. Restore a clean lockfile. Revert the lockfile to a commit from before the bad version resolved, then reinstall with npm ci (which installs exactly what the lockfile says and fails if package.json disagrees) or pip install --require-hashes -r requirements.txt. Clear the local caches (npm cache clean --force, pip cache purge) so the bad tarball is not served back to you from disk.
  7. Report what you find. If you see a package or indicator that is not in the records, report it to the registry (npm and PyPI both have abuse reporting) and to the OpenSSF malicious-packages project, so the next team gets a better record than you did.

What would have helped

Strip away the names and the batch has one shape: code that runs at install or first import, version numbers chosen to win resolution, and evasion aimed at automated reviewers rather than at people. None of the controls that follow is exotic. Most are configuration the package managers already support, roughly cheapest first.

Pin internal scopes to your internal registry

The dependency-confusion packages only work when a build can resolve an internal-looking name from the public registry. For npm, map every internal scope to your private registry in .npmrc:

@webapp-shared:registry=https://npm.internal.example.com/
@your-org:registry=https://npm.internal.example.com/

For pip, avoid --extra-index-url for private packages; it makes pip consider both indexes and pick the highest version, which is the exact behaviour these packages exploit. Use a single index that proxies the public one, or tools that support per-package index pinning. Then claim your internal scope names on the public registry so no one else can.

Turn off install scripts by default

npm can refuse to run lifecycle scripts globally:

npm config set ignore-scripts true

Then allow scripts only for the packages that genuinely need them (native modules you already trust), either by running their build steps explicitly or by using a package manager that supports an allowlist of packages permitted to run build scripts. This one setting would have kept the postinstall step described in these records from running. It does not stop the code paths that run on require() or import, so it is a layer, not a fix.

For Python, prefer wheels to source distributions so setup.py does not run at install time: pip install --only-binary=:all: refuses source builds. Several of the PyPI records say the package "overrides the install command in setup.py", which only fires on a source install.

Install exactly what you reviewed

npm ci installs from the lockfile and fails on drift. pip install --require-hashes fails if any artifact's hash differs from the one you recorded. Both turn a silent upgrade to a malicious point release into a failed build that someone has to look at.

Wait before adopting brand new versions

Most packages in this batch were flagged within hours to days of upload. A short cooling-off period for new versions removes most of that window. Some package managers and resolvers now support a minimum release age or an "exclude newer than" date for resolution; where yours does, a few days is a reasonable default for most dependencies, with an exception path for urgent security fixes.

Stop known-malicious packages at the fetch, before anything runs

The strongest control is the one that refuses the package before it reaches the machine, because it does not depend on every developer configuring every tool correctly.

Safeguard's Package Firewall is built for that point in the flow. Per its documentation, it runs "as an install-time proxy fronting npm and pip", so package managers fetch through it instead of the upstream registry, and it can allow, warn on or block each dependency inline. It may block malicious, typosquatted and dependency-confusion packages at install time, before any install script gets a chance to run. Because it sits on the fetch path, the docs describe coverage as "transitive by construction": it sees transitive dependencies pulled in by your direct ones, with no separate lockfile parsing step to keep in sync. You point your package managers at the proxy rather than at the public registries, and the docs say you can run it as a local or CI proxy, as a registry-fronting proxy in front of an existing repository manager, or on the server-side runner, all sharing one decision core.

The docs describe modes that map well to a rollout. Audit mode records what would have been blocked without blocking anything, so you can measure the effect first. Warn lets the install proceed and records the decision. Block refuses the install. Quarantine holds a flagged package aside pending review, and the docs say "behavioral and novel-package signals are held in quarantine pending review", which is the closest thing to a review gate for brand new packages. Every non-allow decision is recorded to the audit trail.

From the Safeguard CLI, the docs show a local or CI proxy and a one-shot check:

# Local / CI proxy for npm and pip
safeguard pkgfw start --mode quarantine --port 8899

# One-shot check of a single package version, printed as JSON (exit 1 on block)
safeguard pkgfw check --ecosystem npm --name black-spindle --version 999.0.0

The one-shot check is useful in exactly the situation this post describes: you have a list of names and versions, and you want a yes or no for each before anyone installs them. To be clear about scope: Safeguard did not find, detect or report any of the packages in this batch. The records credit the OpenSSF Package Analysis project, an independent researcher and a cloud provider's inspection service, and the GitHub advisories republish their work. The Package Firewall is a control you put in front of your installs so that the next batch, whoever reports it, is refused at the door.

For Safeguard's own write-ups on related ground, see how the Package Firewall works for dependencies, deep transitive dependencies and dependency confusion, and why malware records list versions, not packages, which explains the version entries counted at the top of this post.

Fix it without waiting for upstream

Whatever the vulnerability, most of the risk sits in two gaps. The first is the time between a fix being published and that fix actually running in your production builds. The second, and worse, is the time before any fix exists at all, while a maintainer works out a patch and a release schedule. Waiting through both gaps is a habit, not a requirement. Safeguard gives teams three ways to close them.

1. Autonomous remediation: pull requests, not tickets

When a fixed version exists, Griffin, Safeguard's security model, opens a policy gated pull request that moves the affected dependency to the safe version, and your own CI runs your tests on it before anyone merges. You can run it on one repository or in bulk across every project that carries the vulnerable version. Remediation categories include dependencies, licenses, security posture and findings, and each one is set separately. Reachability analysis ranks the vulnerable code paths your application actually calls first, so the first pull requests you merge are the ones that matter most. Auto-merge is a separate switch and stays off by default, so your team decides what merges on its own and what still needs a human approval.

2. Gold Open Source: the safe version in seconds, free

gold.safeguard.sh is free and needs no login. Search any CVE or package to see affected and fixed versions, CVSS, EPSS, CISA KEV status and a recommended safe version, or use the "Am I affected?" checker. The same data is available through a free read API, README status badges and RSS threat feeds.

3. Gold Registry: start from components that are already fixed

Safeguard Gold rebuilds patched versions of open-source packages and images from source, signs each build, attaches an SBOM, SLSA provenance and VEX, and serves them from registry.safeguard.sh to your organization. Every Gold artifact is held continuously to zero exploitable critical or high CVEs and a completed license check, with signed build provenance (signed with Safeguard's key and verifiable with cosign, SLSA Build Level 3 as the design target) and a public evidence page you can hand to an auditor. An artifact loses its Gold badge if it regresses. Teams can point their own package directory at the registry so hardened equivalents resolve without changing how developers install anything.

When upstream has no fix yet, or will not ship one, Safeguard first works to get the fix accepted upstream. If the issue is severe or upstream will not move, Safeguard builds a fork, fixes the vulnerability there and publishes a hardened Gold build, so you are not stuck on a maintainer's release schedule. You can also request a hardened build of a specific package. Ask which packages it covers before you plan around it.

Your situationWhat to use
A fixed version existsAutonomous remediation opens the upgrade pull request; Gold Open Source confirms the safe version
You need to know today whether you are affectedGold Open Source lookup and "Am I affected?" checker, free and without login
You want vulnerable versions to stop shippingSafeguard policies and gates in CI
Upstream has no fix yet, or will not ship oneA custom Gold request; fork and patch
You want to start clean on new projectsPull hardened components and images from Gold Registry

One honest limit: when the vulnerable code lives in a vendor appliance, a hosted service or an operating system runtime you do not build yourself, the fix still has to come from that vendor or distribution. The steps above apply to the open source dependencies and container images you build and ship, which is where most teams carry the largest and least tracked share of their exposure.

A malicious package is a different problem from a vulnerable one in one important way: there is no "fixed version" to upgrade to. The fix is removal, plus making sure nothing re-resolves it. Two parts of the block above still apply directly. Policies and gates in CI keep a listed package from coming back through a pull request after you have removed it. And resolving your dependencies from a registry of rebuilt, signed components, rather than straight from the public registry, narrows the set of names a build can pull in the first place.

Frequently asked questions

Is the number 106 or 112 or 172? All three, depending on what you count. As of 28 October 2026, 23:10 UTC, GitHub's advisory database lists 112 malware advisories published between 26 and 28 October 2026, covering 106 distinct packages (96 npm, 10 PyPI) and 172 affected version entries (156 npm, 16 PyPI). New advisories are added continuously and older ones are sometimes updated, so rerun the API query if you need a current figure.

Are the "test" and "poc" packages really malicious? The records classify them that way, and some carry a "PROBABLY_PENTEST" label that signals lower harm. From the installer's side, the behaviour described (sending your hostname and paths to a third party, or publishing under your npm identity) is something you did not consent to, so treat them the same way in cleanup.

Did Safeguard find these packages? No. The records credit the OpenSSF Package Analysis project, an independent researcher's PyPI feed and a cloud provider's inspection service. This post summarizes their public records.

What is the fastest single step to stop the next batch? Put an install-time control in front of your package managers so known-malicious, typosquatted and dependency-confusion packages are refused before they are fetched, and set ignore-scripts for npm as a second layer.

See how Safeguard would have helped

Each demo below is a recorded walkthrough of a real product flow. None of them shows this batch of packages. Here is how each flow applies.

Safeguard CLI. The demo is a real CLI session: you install it, open the interactive terminal with safeguard, and ask it to fix the lodash CVE in a repository. It reads the finding and the advisory, asks before any command that changes something, edits package.json to lodash 4.17.21, runs npm install and npm test with approval, prints a summary and pushes nothing. For this incident you would use the same session to run the lockfile check across your repositories, run safeguard pkgfw check on any hits, and have the assistant remove the package, restore the lockfile from a clean commit and rerun your tests.

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

Local runner. The demo sets up the runner on your own machine, approves it with its device code, then scans from the terminal. For this incident, the same setup puts the scan on the build hosts and laptops that actually fetch packages, which is where the records say the damage happens.

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

Claude with Safeguard. The demo connects Claude to the Safeguard MCP server with OAuth and shows how you choose which tools Claude can use. For this incident, that connection lets you ask about your projects' dependencies in plain language while keeping the assistant limited to the tools you allowed, which matters when the threat in question writes to assistant configuration files.

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

To try it on your own machine, download Safeguard and follow the CLI documentation.

Quick check

An advisory lists a package with the range "= 16.0.5". You have version 16.0.4 installed. Is it covered by that advisory?

No. A single-version range covers only that version. Advisories that cover every version use a range such as ">= 0". Your version is not listed, though you should still ask why a package with a malicious release is in your tree at all.

Why did the dependency-confusion packages use versions like 99.0.1?

To win version resolution. A build that can see both a private and a public registry for the same name will usually pick the highest version, and 99.0.1 outranks any realistic internal version.

You set npm's ignore-scripts to true. Which behaviour described in the records still runs?

Code that runs when the package is loaded. Several records say the same beacon code also runs from index.js on require or import, so blocking install scripts alone is not enough.

Sources

Last reviewed: 29 October 2026. This is a developing story; check the linked advisories for the latest status.

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.