Back to all posts
Published on · by Renaud Deraison

One every three days

StepSecurity counted a year of malicious-package attacks on Saturday: 56 of them, 6 in the first six months and 50 in the six and a half since February, about one every three days since March. Then it named why they keep working: secrets on developer machines and CI runners, unpinned latest versions, open outbound network access. Not one of those is a property of a package. All three describe the machine the install runs on, and the machine that installs the most packages now has a coding agent on it.

A malicious package fires at install. There is no vulnerable code path to reach, no exploit to write, no window to patch in. At one every three days, the package it arrives in has stopped being the useful thing to ask about.

On Saturday, StepSecurity published a twelve-month count of open-source supply chain attacks. Fifty-six of them. "In the six months from August 2025 through January 2026, we tracked 6 incidents. In the six and a half months since February 2026, we tracked 50." Since March the rate has held at roughly nine a month, or "about one every three days."

StepSecurity sells a filtering registry, so read the report knowing what it is selling. The count holds up anyway. It is the most careful public tally of malicious packages anyone has published, and the definition behind it is narrow on purpose.

What the number is counting

The report throws out vulnerabilities. No CVEs, no unsafe block that turned out to be. It counts packages someone poisoned on purpose.

That is why the number is usable. A vulnerability needs a second event: someone has to find your instance, reach the affected code path, and get something out of it. You can run a vulnerable version for a year and nothing happens. A malicious package skips all of that. As the report puts it, it "runs the moment a developer machine or CI runner installs it." The install is the exploitation. You never get a window between disclosure and patch, because nobody disclosed anything and nobody shipped a patch. You get a version number, then a scramble to work out who fetched it.

March 2026 saw 13. April 9, May 9, June 10, July 6. In five days in March, one operation tracked as Team PCP took 78,330 secrets from 2,186 organizations, 92 of them public companies, by poisoning 76 of the 77 tags on the Trivy GitHub Action and a handful of other build-time components. axios, at over 100 million weekly downloads, shipped two malicious versions carrying a cross-platform remote access trojan. Six worms appear in the year: Shai-Hulud twice, CanisterWorm, Mini Shai-Hulud, Miasma, and ChainDrop this month. npm, PyPI, RubyGems, Composer, crates.io, GitHub Actions. "Every major package ecosystem and CI/CD platform has been hit."

Two entries in that list matter here. The report notes that hackerbot-claw, "an AI powered bot," went after GitHub Actions at Microsoft, Datadog and the CNCF, and that "the Miasma worm specifically targeted AI coding agents."

The three conditions, none of which is a package

Then the report does the useful thing. Under a heading asking why the attacks keep working, it gives three answers: "secrets in CI/CD pipelines and developer machines, unpinned latest versions, open outbound network access."

Nothing on that list describes a package. No obfuscation, no typosquatting distance, no install-hook cleverness. All three describe the machine that runs the install: what it is holding, how it picks a version, and where it is allowed to connect. Change any one of them and most of the fifty-six stop paying.

Unpinned latest

Something in the resolution chain says "newest." A floating range, a transitive dependency, a rebuilt lockfile, an agent adding a library on your behalf. The attacker only needs the resolver to prefer a version published minutes ago.

Secrets on the machine

~/.ssh, ~/.aws, ~/.kube, ~/.npmrc, a GH_TOKEN in the environment, a registry login in ~/.docker/config.json. None of it belongs to the build, and whatever the build just started can read all of it.

Open outbound

The stolen material has to leave, and the second-stage binary has to arrive. Both are ordinary outbound connections from a machine that allows outbound connections to everywhere.

What has to line up1 · the packagea poisoned version ispublished, and lives foran hour or two2 · the resolversomething in the chainasks for newest, andnewest is the poisoned one3 · the diskinstall-time code reads~/.ssh ~/.aws ~/.npmrcand the environment4 · the wirean outbound connectioncarries it out, and bringsthe second stage backsteps 2, 3 and 4 are decided by your machine, not by the package“secrets in CI/CD pipelines and developer machines, unpinned latest versions,open outbound network access” — StepSecurity, 22 August 2026this is theonly part youcannot control
A malicious package attack needs four things to line up, and only the first one is about the package. The other three are properties of the machine: how it picks a version, what it is holding, and where it is allowed to connect.

Whose machine installs the most packages now

The defence everyone ran on was attention. You read the diff. You notice that a caching library picked up a dependency named after a date formatter with one letter changed.

Two things have overtaken that. The rate, first: nobody reviews transitive dependencies every three days. Then who does the installing. A coding agent adds a library because you asked it to build something, resolves the tree, runs the install, and reports that the tests pass. You read the diff it produced, which is the part worth your time. A tarball never shows up in a diff. StepSecurity's list already includes a worm that went looking for that workflow.

Keep the agent. Move the control somewhere its speed stops mattering.

Three conditions, three controls, all on the Mac side

Bromure Agentic Coding runs the agent in a disposable Ubuntu VM on Apple's Virtualization framework, with only the folders you chose mounted in and a host-side proxy as its single route out. That placement puts each of StepSecurity's three conditions in front of a control the build cannot reach.

Unpinned latest becomes a clock. Every package fetch crosses the host proxy first: npm, PyPI, Cargo, RubyGems, Maven, NuGet, Go modules, Packagist. The age gate is on by default at two days. Floating references resolve to the newest version older than the cutoff, so latest keeps working and stops meaning "published while you were reading this." A pinned reference to something too fresh comes back as a 451 with a Bromure error on it. On top of that you can switch on OSV lookups at a severity threshold you pick, route packages through socket.dev's reputation database or Delpi's filtering registry, and strip preinstall, install, postinstall and prepare out of npm tarballs on the fly. Bromure rewrites the tarball and updates the registry metadata hash so npm's own verification still passes. The .npmrc and pip.conf inside the VM can tighten every one of these settings and cannot loosen them.

Secrets on the machine are stubs. Real credentials never enter the VM. The proxy swaps a placeholder for the real token on the wire; the kubeconfig in the guest is synthetic with throwaway client certificates; AWS gets a credential_process helper that hands the SDK a forty-character fake while the host re-signs SigV4 with the real material, so anything routing around the proxy collects an InvalidSignatureException from Amazon. Install-time code that reads ~/.aws/credentials finds a file, reads it, and exfiltrates a decoy. Each credential also carries a write policy (Off, Block destructive, or Read-only) enforced in the host proxy, plus Require approval to use, which turns any single use into a consent dialog with a grant of five minutes, an hour, or the rest of the session.

Open outbound answers to a rule you wrote. Under Guardrails, the Outbound connections table is a per-profile firewall: one row per rule, each with an action, a protocol (tcp, udp, web or any), a host or CIDR, ports, and for web a list of HTTP methods. Rules match top to bottom, first match wins, and Unmatched traffic is a switch you set to Allow or Deny. Two layers enforce the same ruleset: the virtual switch by destination IP and by hostnames it learned from snooping the guest's own DNS answers, and the proxy by TLS SNI. Transparent interception is on by default, so a build script that unsets HTTPS_PROXY changes nothing about which connections leave.

Last week's crates.io attack shows what that rule catches. In StepSecurity's write-up, the dropper's build script decoded a base64 URL and fetched a platform-specific binary from a bare IP on port 9089, with certificate validation disabled, then detached the child process so Cargo would not wait on it. With unmatched traffic denied, that fetch never completes, and the build script's exit code stops being an interesting question.

One install, two machinesOn the laptopthe resolvertakes whatever was published lastthe install scriptsrun as you, before any code is importedthe credentials~/.ssh ~/.aws ~/.npmrc $GH_TOKENthe wirereaches any host, unloggedIn a Bromure workspaceage gate, on by default at two daysfloating refs resolve under the cutoff · apinned too-fresh version gets a 451stripped from the tarballmetadata hash rewritten so npm still verifiesstubs, swapped at the wirereal values stay on the Mac · SigV4 re-signedunmatched: deny · logged in the Security Log
The same four steps, run inside a Bromure workspace. Step one still happens, because nobody can stop someone publishing a bad version. Steps two, three and four each meet a control that lives on the Mac, outside anything the install can write to.

Eighty-six minutes

Go back to that same attack for why a clock beats a judgement call. The poisoned releases of arrayref, internment and append-only-vec lived 86, 90 and 107 minutes before crates.io pulled them. To steer resolvers onto them, the attacker yanked the clean versions, 0.3.9 back through 0.3.5, in a burst that took sixteen seconds, leaving one release nobody had withdrawn. StepSecurity's sharpest line on the incident: "The attacker turned the registry's own safety feature into the delivery channel." And arrayref itself carried no malicious code. The payload rode in through a build-time typosquat of proc-macro2.

A two-day age gate never has to recognise any of that. An eighty-six-minute-old version is younger than the cutoff, and by the time it is old enough to install, crates.io has already deleted it. The attacker built a window shorter than the one the gate ignores.

Attack fifty-seven

The report closes on a sentence worth keeping: "56 attacks in 12 months. Attack number 57 is already being built."

You will not know which package carries it, and you will not read it. Neither will your agent. Five months at one every three days runs well past the cadence anyone reviews at, and calling that carelessness gets nobody anywhere. You do get to decide what it lands on: whether the version it needs is one your resolver will take, whether the secrets it goes looking for are real, and whether the address it wants to reach is one your machine will route to. Three settings, all of them on your side of the boundary.


Sources: StepSecurity, "The State of Open Source Supply Chain Attacks" (Aug 22, 2026) · StepSecurity, "Rust Supply-Chain Attack: arrayref, internment, and append-only-vec Poisoned by the proc-macro1 Build-Time Dropper" (Aug 20, 2026) · StepSecurity, "Team PCP Stole 78,330 Secrets From 2,186 Organizations" (Aug 13, 2026)