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."
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.
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.