Three npm supply-chain attacks hit in four weeks. None of them needed a stolen password.
Miasma, the Leo Platform worm, and a recon campaign all walked past npm’s 2FA and OIDC defences by going after the CI runner instead of the registry account.
Three npm supply-chain attacks, one underlying pattern
Three npm supply-chain attacks landed within four weeks this spring: a reconnaissance campaign planted across 33 packages (Microsoft, 29 May), a forged-provenance attack on 32 Red Hat package releases that researchers named Miasma (1 June), and a worm that hit 20 Leo Platform packages in a three-second publishing burst (24 June). Each used a different entry point and a different payload. None of them needed a maintainer’s npm password.
That detail is the whole story. npm spent the past three years hardening against exactly that scenario: a phished or leaked long-lived token used to publish a malicious release under someone else’s name. The fixes were real and they worked. npm phased in mandatory two-factor authentication for publishing high-download packages, then added OIDC-based Trusted Publishing, which swaps a long-lived token for a short-lived one minted per build, and SLSA provenance attestations recording which workflow produced a given release. Two of the three attacks this cycle never touched any of that machinery. The third used it exactly as designed and still came out with a worthless guarantee.
None of this means the npm ecosystem has stopped getting safer; on every metric the registry’s own security team would have tracked five years ago, it has. What it means is that the metric itself has moved. The interesting question by June 2026 isn’t “did the attacker steal a password” — it’s “what did the attacker have to control instead,” and the answer, three times running, was some piece of the CI pipeline rather than the registry account.
Miasma: a real attestation on a forged build
On 1 June, an attacker with access to a compromised Red Hat employee GitHub account pushed orphan commits to three RedHatInsights repositories in two waves, starting at 10:53 UTC and again at 13:44 UTC. The commits added a GitHub Actions workflow set to trigger on push to any branch, carrying two permissions: contents: read and id-token: write. That second permission is the one that mattered. It let the workflow request an OIDC identity token from GitHub and exchange it for a short-lived npm publishing credential, the same mechanism Trusted Publishing offers to any legitimate maintainer.
The workflow ran, minted its credential, and published 32 compromised releases across the @redhat-cloud-services namespace, including versions of frontend-components, compliance-client and rbac-client, averaging roughly 80,000 weekly downloads between them. The payload, a heavily obfuscated file derived from the Shai-Hulud malware family, used eval() calls and rotating character encoding, and generated a uniquely encrypted version of itself for every infection so hash-based detection had nothing stable to match against.
Every one of those releases carried a valid SLSA provenance attestation. That’s not a contradiction; it’s what provenance actually checks. A SLSA attestation answers exactly one question: did this artefact come from this recorded build? Miasma’s build genuinely did run the workflow it claimed to run, on Red Hat’s own infrastructure, under a real Red Hat identity. Provenance has no opinion on whether that workflow file should have existed in the first place, only on whether the artefact matches the workflow that exists.
“An attestation proves a build happened. It says nothing about whether the workflow that produced it was the one anyone signed off on.”
The Leo Platform worm: reading secrets out of memory
Three weeks later, on 24 June at 23:04:55 UTC, twenty npm packages belonging to the Leo Platform ecosystem were published with malicious code in a single automated burst spanning under three seconds, a level of coordination that rules out a manual operation. Combined, the affected packages carried around 13,600 weekly downloads.
The delivery mechanism, which researchers nicknamed Phantom Gyp, abused a binding.gyp install hook (a configuration field meant for native-module build instructions) to execute an arbitrary shell command during npm install without ever declaring it as a script in package.json. That command pulled down the Bun runtime from GitHub’s own release infrastructure and ran the real payload, wrapped in three separate layers of obfuscation.
Once running on a GitHub Actions runner, the payload located the Actions Runner process and read directly from /proc/{pid}/mem to recover secrets that GitHub masks in step output but never actually removes from the runner’s memory. From there it harvested credentials for AWS, GCP, Azure, HashiCorp Vault, Kubernetes, npm, GitHub personal access tokens and password managers, then encrypted the haul and committed it to a repository using the victim’s own GitHub token. No external domain ever appeared in the traffic for a network monitor to flag. Any npm token it found got reused immediately, through a bypass_2fa routine, to push the same malicious code to other packages that maintainer owned. On GitHub-hosted runners, the payload also rewrote /etc/sudoers to grant itself root.
The reconnaissance campaign that didn’t need to steal anything yet
The earliest of the three, reported by Microsoft on 29 May, looked different on the surface: 33 packages published under nine corporate-sounding scopes such as @cloudplatform-single-spa, @t-in-one and @sber-ecom-core, each versioned 100.100.100 so it would win npm’s dependency resolution against any real internal package sharing that scope name. That’s a textbook dependency-confusion attack, and on its own it’s not new.
What made it worth a second look was the payload’s behaviour once installed. It ran in what Microsoft’s researchers called recon-only mode, collecting hostnames, environment variables and developer context, and sending none of it anywhere destructive. A hard-coded flag controlled that mode. That same flag was designed to be toggled server-side, switching the exact same install hook from passive collection to credential theft or backdoor installation, with no need to publish a new version or trip whatever scanner caught the first one. The campaign wasn’t an attack yet. It was a map of which organisations used which internal package names, built so the operators could decide later who was worth the second step.
That staging pattern is worth taking seriously on its own terms, separate from whatever comes after it. A scanner tuned to catch credential exfiltration or destructive behaviour has nothing to flag in a package that only reads its own environment and goes quiet. By the time the flag flips, the package has usually been sitting in a dependency tree, trusted and unremarkable, for weeks.
Why 2FA and OIDC didn’t stop any of this
Line these three up and the gap is the same one each time, just reached by a different door. Two-factor authentication protects a human logging in to publish from a browser or CLI session, and none of these attacks did that. OIDC Trusted Publishing replaces a long-lived npm token with one minted per workflow run by GitHub; Miasma used precisely that mechanism, just behind a workflow file the legitimate team never wrote. Recon-only malware does not need either, because it is not trying to publish anything. It is trying to learn enough to decide where to spend a real attack later.
The actual trust boundary that moved over the past three years is this: it used to be “does this npm account belong to a human who passed two-factor,” and it is now “does this CI job have permission to mint a publish credential.” That second question depends entirely on who can edit a workflow file and what else is allowed to run on the machine executing it. The registry hardened. The runner didn’t, and for most teams it was never part of the same security review, because it used to belong to a different team with a different threat model: build reliability, not credential custody.
What actually needs to change in a CI pipeline
None of the fixes here require waiting on npm or GitHub to ship something new. They’re pipeline configuration choices that most teams haven’t made yet because the threat model they were defending against was last year’s.
Scope id-token: write to exactly one job in one reviewed workflow file, gated behind a GitHub Environment with required reviewers, rather than a repo-wide permissions block. Treat any push-triggered workflow that holds publish permissions as a deploy path and apply the same branch-protection and review rules a production deploy would get. As Miasma showed, that’s exactly what it is. Prefer ephemeral, single-job runners over long-lived self-hosted ones for anything touching cloud or registry credentials; a runner destroyed at the end of one job gives a memory-scraping payload or a sudoers rewrite a much smaller window to matter.
A fourth control is worth adding even though it would not have caught the Leo Platform worm specifically, since its exfiltration route avoided the network entirely: default-deny egress on CI runners, with an explicit allow-list for the registries and APIs a build actually needs. Most supply-chain payloads still do rely on an outbound connection somewhere, and removing that option by default raises the cost of every future variant that does.
name: publish
on:
push:
tags: ['v*']
permissions:
contents: read # no token-minting rights at the top level
jobs:
publish:
runs-on: ubuntu-latest
environment: npm-publish # requires a manual reviewer approval
permissions:
id-token: write # scoped to this job only
contents: read
steps:
- uses: actions/checkout@v4
- run: npm ci --ignore-scripts
- run: npm publish --provenanceThat ignore-scripts flag is doing real work in the example above. Disabling npm install scripts by default in CI, with an explicit allow-list for the small number of packages that genuinely need a native build step, would have blocked the Leo Platform worm’s entire delivery mechanism on its own. Phantom Gyp depends on an install hook running at all. And treat a provenance requirement as a starting point, not a finish line: it confirms a workflow produced an artefact, but someone on the team still has to be the one who checks which workflow that was.
The pattern repeats faster than the fixes
This is the third time in three years the npm ecosystem has had to retire an assumption it spent the previous cycle building. Long-lived tokens gave way to two-factor authentication and short-lived OIDC credentials. Now the CI runner itself, its memory, its install hooks, its workflow files, is the layer attackers have moved into, and most security reviews still stop at confirming that 2FA and provenance are switched on. As of this June, the honest answer to that question is that switching them on was never going to be enough by itself.
None of the three incidents here required a novel exploit. Compromising one employee’s GitHub account, reading a runner’s own process memory, and registering a package with an inflated version number are all old techniques aimed at a part of the pipeline that simply wasn’t locked down yet. The fix isn’t a new product category. It’s treating CI configuration with the same scepticism teams already apply to production infrastructure, on the assumption that whatever isn’t reviewed eventually gets used.
Frequently asked questions
Related reading
An AI agent deleted PocketOS's production database in 9 seconds. Credential scoping was the real failure.
A Cursor agent found one unscoped API token and wiped a production database and its backups in nine seconds. The real failure was credential scoping, not the model.
Flaky tests aren't random. Six root causes explain almost all of them.
Retrying a failed CI job treats every flaky test as the same problem. Research from Google, Microsoft, and Atlassian shows flakiness has six distinct root causes, and the fix for one works against another.
The One Medical breach claim is what M&A security debt looks like, five years later
A 2026 breach claim against Amazon's One Medical traces back through two old acquisitions. Yahoo and Marriott show why: due diligence audits what's active at close, not what gets orphaned afterward.