Back to all posts
Published on · by Renaud Deraison

The tell was the timestamp

npm shipped its biggest security change in sixteen years on July 8, 2026 — install scripts off by default. Within a week, two separate npm compromises moved their payload to fire when the package is imported instead of when it installs, routing straight around the new default and around --ignore-scripts. The mechanism the ecosystem just spent a year learning to watch had already moved. The one thing that didn't move was how old the malicious version was — and that is the signal Bromure's age gate checks.

The whole ecosystem spent a year learning that npm install scripts were the danger. On July 8 npm turned them off by default. Three days later a compromise moved its payload out of the install script; three days after that, a second one shipped with no install script at all. Both fire when you import the package. The fix and the workaround crossed in the same week.

A developer adds @asyncapi/generator to a project on July 14 and runs npm install. The package has no preinstall, no install, no postinstall hook — nothing for a script scanner to flag, nothing for npm's brand-new default to skip. The install finishes clean. The first time the code does require('@asyncapi/generator'), a loader wired into the package's own entry point spawns a detached Node process, pulls an 8.2 MB second stage over IPFS, decrypts it, and starts hunting for credentials. Nothing about the install looked wrong, because the part that was wrong did not happen at install.

The fix that shipped for last year's attack

For most of a year, the story of npm supply-chain attacks was the same story: a compromised package carries a postinstall hook, and npm install runs it automatically on your machine. So on July 8, npm shipped version 12 — the largest security redesign in the tool's sixteen years. Lifecycle scripts from dependencies are now off by default: "preinstall, install, and postinstall scripts from dependencies do not run unless you allow them." Git dependencies and remote-URL sources became opt-in too. It is a real improvement, and it closes a door that a year of worms walked through.

It closes one door. The attackers had already found the other one.

The payload moved to import time

On July 11, the jscrambler npm package was compromised — five malicious versions published over about three hours. The first three, 8.14.0 through 8.17.0, "ran the dropper from a preinstall hook," the classic pattern. Then, mid-campaign, the attacker changed the door. "Starting with 8.18.0 the install hook is gone entirely — the identical dropper is instead injected as a self-executing function at the top of dist/index.js," so it "fires when the package is imported or its CLI is run rather than at install time." Socket names the reason plainly: "This is a deliberate evasion: it defeats scanners that only inspect preinstall/postinstall scripts and survives npm install --ignore-scripts."

Three days later, the @asyncapi npm organization was compromised the same way, but built for it from the start. Five versions across four packages — @asyncapi/specs 6.11.2-alpha.1 and 6.11.2, @asyncapi/generator 3.3.1, @asyncapi/generator-components 0.7.1, @asyncapi/generator-helpers 1.1.1 — were republished after an attacker used a misconfigured GitHub Actions workflow to leak the project's bot token. Microsoft's finding is the one that matters here: "All affected packages declared no preinstall, install, or postinstall hooks in package.json." The loader sat inside each package's normal entry point instead — index.js for the specs, a validator.js deep in the generator's templates. And the mitigation advice is explicit: "Do not rely on npm install --ignore-scripts as a mitigation; this campaign executes when the module is imported, not through a lifecycle hook."

jscrambler · Jul 118.18.0: hook removedAsyncAPI · Jul 14no hook at allINSTALL-SCRIPT DOORpreinstall / postinstallshut by npm v12 defaultshut by --ignore-scriptsclosedIMPORT-TIME DOORrequire() / importloader in the entry pointnpm v12 does not touch itopenSAME PAYLOADdetached node process8.2 MB stage over IPFScredential harvesterC2 · 85.137.53.71
Same payload, a different door. For a year the payload came through the install-script door: npm runs preinstall/postinstall automatically. npm v12 (July 8) shuts that door by default, and --ignore-scripts shuts it manually. But a second door leads to the same room — the code that runs when you import the package. jscrambler (July 11) moved its dropper from a preinstall hook into dist/index.js; AsyncAPI (July 14) shipped with no install hook at all, its loader wired into the package's own entry point. Closing the install door does not touch the import door.

Two unrelated actors, three days apart, both landing on the same move in the same week the ecosystem's marquee fix went live. That is not coincidence. It is what happens when a defense names a mechanism: the mechanism is a place to stand, and standing somewhere else costs the attacker a few lines of code. A scanner that asks "does this package have an install hook?" gets a truthful "no" from AsyncAPI, and waves it through.

The one thing that did not move

Strip away the delivery mechanism and look at what stayed constant across both compromises. The jscrambler versions were live for a few hours. The AsyncAPI stable release was published at 08:30 UTC and downstream detection started at 08:49 — nineteen minutes. Every malicious version in both incidents was pulled well inside two hours of going up. The payload moved from the install hook to the entry point; the delivery moved from a lifecycle script to a runtime loader. The one property that did not move is that each poisoned version was brand new. A freshly published version is the shared fingerprint of a compromise, because that is the shape of the attack: steal a token, publish, get caught, get yanked. The window is short by construction.

That is the property Bromure's age gate checks, and it is on by default. Every package fetch — npm, PyPI, Cargo, and the rest — passes through the host man-in-the-middle proxy before the agent's install ever sees a byte, and the proxy refuses any version younger than the cutoff, two days out of the box. A floating reference like latest or a semver range silently resolves to the newest version that is old enough; a pinned reference to a too-fresh version comes back as a 451 with a Bromure error explaining why. It never asks where the payload lives. It does not care whether the code fires at preinstall or at require(), whether the hook is present or absent, whether --ignore-scripts is set. A version published nineteen minutes ago is refused because it is nineteen minutes old. Both of these campaigns were dead on arrival against a two-day gate — not because Bromure recognized the payload, but because it never looked at the payload at all.

MECHANISM CHECK · ASKS ABOUT THE PAYLOAD"does it have an install hook?"preinstall / install / postinstallAsyncAPI answers, truthfully:nopasses the checkloader still fires at require()BROMURE AGE GATE · ASKS ABOUT THE VERSION"how old is this version?"cutoff: 2 days, on by defaultregistry says published:19 minutes agorefused · 451payload never resolves
Two ways to decide whether to let a package through. A mechanism check asks a question about the payload — does it have an install hook? — and AsyncAPI answers 'no' truthfully, so the check passes it. Bromure's age gate asks a question about the version — how old is it? — and a version published nineteen minutes ago is refused with a 451 regardless of where its code fires. The mechanism moved from install-time to import-time between two attacks in one week; the age did not move, because a fresh publish is the shape of the compromise.

The age gate is not a claim that new packages are malicious — most are fine, and a two-day-old release is still very new. It is a claim about where the risk concentrates. The dangerous window for a compromise is the first hours after a stolen token pushes a version, before a maintainer notices and pulls it. Waiting two days does not make a package safe, but it moves your install out of the exact interval these attacks live in, and it does so without inspecting a single line of the code you are about to run. For a package you need on the day it ships, the gate takes a per-package exemption; everything else waits its two days.

When a version is old enough anyway

An age gate is a filter, not a wall. Set a shorter cutoff, exempt a package you need on day one, or pull a malicious version that stayed undetected long enough to age past two days, and the poisoned code reaches the install. Bromure's answer for that case is the one the earlier supply-chain posts describe, and it is the reason the age gate can afford to be a filter rather than the whole defense: the agent that runs the install is not on your Mac.

It is inside a disposable Linux VM, one hypervisor boundary away. So when the AsyncAPI loader fires and its credential harvester goes looking — and it looks hard, sweeping more than a hundred environment variables including NPM_TOKEN, AWS_ACCESS_KEY, and ANTHROPIC_API_KEY, plus .npmrc, ~/.aws/credentials, id_ed25519, and kubeconfig on disk — it finds the VM's copies, which are placeholder brm_… strings. The real values live on the host. When the agent makes a legitimate request that needs one, the host proxy swaps the fake for the real value on the wire, for that single hop, and swaps it back; the secret never lands in the VM's memory for the harvester to scrape. The ~/.ssh key the payload copies is a throwaway pair minted for that profile. And the C2 upload the payload attempts — to 85.137.53.71, in this case — leaves through the same host proxy that logs every destination, so a Node process you never started opening a socket to an unknown IP is a line in the Security Log, not a silent transfer. When the window closes, the VM and everything the payload did inside it are gone.

This is the same Miasma runtime family that rode into Red Hat's own npm scope earlier this year. The harvester is mature and reused across campaigns; the delivery mechanism is the part that keeps changing.

The uncomfortable lesson of this particular week is that a defense pinned to a mechanism has a half-life measured in days. The ecosystem spent a year making install scripts the thing to watch, shipped the fix, and watched two separate actors relocate the payload before the release notes were cold. You cannot win that race by naming the next mechanism, because there is always another place for the code to fire. You win it by checking something the attacker cannot cheaply change — how old the version is — and by running whatever slips past somewhere it can take nothing with it. Try it.