Skip to main content

npm supply chain attacks in 2025 and 2026: what the Shai-Hulud worm changed for CI

On 2025-09-14, at about 17:58 UTC, a malicious version of an npm package was published. Within two days at least 187 packages were infected. The payload did something earlier npm compromises had not: it looked on the developer's machine for an npm token and, when it found one, republished itself into the 20 most popular packages that token could write to. Each install was a new publisher.

The same payload ran an open-source secret scanner across the machine, gathered what it found, and pushed the results to public GitHub repositories named "Shai-Hulud". A dependency compromise had turned into a credential-harvesting loop that fed itself.

The August 2025 compromise of the "nx" build tool, a few weeks earlier, did not self-propagate. Shai-Hulud did. That is the change that matters for anyone running CI, and it is why the registry's token model changed three months later.

How the loop worked

Three ingredients made the worm possible, and none of them was a software vulnerability in the usual sense.

The first was a long-lived publish token on a developer machine or a CI runner. npm "classic" tokens did not expire and were not scoped to a session, so a token minted a year earlier for a release script still worked.

The second was a postinstall hook. npm runs lifecycle scripts during install with the privileges of the installing user, so a package can read the home directory, the environment and the git credential store the moment it lands.

The third was reach. A single maintainer's token often has write access to dozens of packages. The worm chose the 20 most downloaded ones it could reach, so each hop maximised the number of fresh machines that would install it. At least 25 packages maintained by a large security vendor were briefly compromised this way.

Put together: install a dependency, lose your token, and your own packages become the next vector. Nothing in that chain requires the attacker to come back.

What GitHub changed in December 2025

On 2025-12-09 GitHub permanently revoked all npm classic tokens. npm login now issues a two-hour session. Granular write tokens are capped at 90 days. New packages get two-factor authentication by default, and the recommended path for CI publishing is OIDC trusted publishing, where the runner proves its identity to the registry per job and no long-lived secret exists to steal.

This closes the first ingredient. A worm that lands on a CI runner using trusted publishing finds nothing it can reuse. One that lands on a laptop finds a session that dies in two hours.

It does not close the second or the third. Lifecycle scripts still run. A maintainer with 2FA can still be phished. And nothing in the registry changes stops you installing a package that was malicious from its first version, or one that does not exist at all until an attacker registers the name your AI assistant invented.

The half that happens on your side

Registry hygiene is GitHub's half. The other half is a check on what your manifests and your coding assistant are about to pull in, before the install runs.

Vulkro's slopcheck reads a dependency manifest and classifies each name against built-in data, offline, with no network call. Four classes come back: known-malicious (High) for names on the compromised-release list, hallucinated (High) for names that AI models are known to invent, typosquat-of (Medium) for names one or two edits away from a popular package, and suspicious-suffix (Low) for a popular name with a decoy suffix. Documentation: vulkro slopcheck.

# Before npm install, in the PR lane
vulkro slopcheck --manifest ./package.json

Exit codes follow the standard contract: 0 when nothing is flagged, 1 when a name is flagged, 2 on error. In a pipeline that means the install step never runs on a manifest that names a known-bad package.

The hallucinated class deserves a note. When an assistant writes import { parse } from "fast-csv-utils" and that package does not exist, the import fails and someone fixes it. When an attacker has already registered fast-csv-utils with a postinstall script, the import succeeds. The OWASP MCP Top 10 project lists this family as MCP04:2025 Software Supply Chain Attacks & Dependency Tampering, because an agent that can edit a manifest and run an install is exactly the actor that triggers it.

Knowing what you ship, and what you reach

After the install, the question becomes inventory. vulkro sbom . --format cyclonedx-1.7 writes a CycloneDX 1.7 bill of materials for the project, and the openvex output records, per vulnerability, whether your code reaches the affected function. Documentation: vulkro sbom.

vulkro sbom . --format cyclonedx-1.7 > sbom.json
vulkro sbom . --format openvex > vex.json

The reachability distinction is what keeps the list actionable. A vulnerable function that is present in node_modules but never called is a different priority from one on a request path. vulkro scan --reachable-only narrows dependency findings to the reachable ones, and --fail-on-reachable-only makes the exit code depend on them alone. Documentation: vulkro scan.

The regulatory clock is relevant here. The EU Cyber Resilience Act entered into force on 2024-12-10. Reporting obligations for actively exploited vulnerabilities apply from 2026-09-11, which has already passed, and the main obligations apply from 2027-12-11. A manufacturer that cannot say which of its products contain a given component, and whether the vulnerable code is reachable, will struggle to meet the reporting deadline when the next worm lands.

What this does not catch

slopcheck works from a list. A package compromised ten minutes ago is not on it until the data is updated, and an offline bundle is as current as its date. A legitimate package whose maintainer is phished tomorrow will pass. The check removes the known-bad, the invented and the look-alike. It does not make a fresh compromise impossible, and nothing that runs on your machine can.

The protection against the fresh compromise is on the registry side: short-lived sessions, trusted publishing, and lockfiles that pin the exact version you tested rather than a range that resolves to whatever was published last night.

What to do this week

  • Check whether any CI job still holds an npm token in a secret store. If it predates December 2025 it is dead; replace it with OIDC trusted publishing rather than a new token.
  • Confirm npm ci runs with --ignore-scripts where you can afford it, and know which packages genuinely need a postinstall.
  • Add vulkro slopcheck --manifest ./package.json before the install step in the PR lane.
  • Generate one SBOM with vulkro sbom . --format cyclonedx-1.7 and keep it with the release, so the next advisory is a lookup rather than an investigation.
  • Read the slopcheck docs for the --list and --ecosystem options if you need to check names outside a manifest.

Vulkro Core comes with a 14-day trial. Start it here and run slopcheck on the manifest your last agent branch touched.