A delivery arrives from a supplier the receiving clerk has dealt with for years. The name on the manifest is the right one, the logo is the right one, and most of the lines match the order. One line is new: a subcontractor nobody at the dock has heard of, whose crate is already inside the building by the time anyone reads the paperwork. Nothing about the familiar name on the header tells the clerk what is in that crate. Only the line itself, and the evidence behind it, can do that.
A week ago today, on March 31, two new versions of Axios, a JavaScript library that programs use to make web requests, appeared on npm, the public registry from which most JavaScript software is installed. Version 1.14.1 was published at 00:21 UTC and version 0.30.4 at about 01:00 UTC. Neither was released by the project in the usual way. The project's lead maintainer, Jason Saayman, wrote in his postmortem last Thursday that both were published through his compromised account, and that they added a dependency the project had never used, a package called plain-crypto-js, at version 4.2.1.2 Researchers at Socket, a software security firm, reported that installing that package ran a program which fetched a remote access trojan for macOS, Windows or Linux.1 Socket puts Axios at about 100 million downloads a week.1
My argument is that trust in the project is not an authorization for every artifact later published under its name. What a build executes is one particular file, downloaded at one particular moment, and the decision to let it run needs verification of the release artifact: which version arrived, where it came from, what changed and what it could reach when it ran. This is not an indictment of open source, whose commercial alternatives have dependencies of their own, and it is not a claim that every application using Axios was compromised. Most were not. Whether a given system was exposed depended on exact versions, on timing and on what happened during installation, which is exactly why the evidence matters more than the name.
Dependency manifest
web-app · build of Mar 31 · illustrative
Requested
Resolved
- Version matches a tagged source release
- New dependency reviewed
- Install scripts allowed to run
The name is the one the team chose, years ago.
The file is the one that runs. Nobody chose this one.
A new line, brought in by the familiar one.
Three places a release lives
It helps to separate three things that the word "Axios" usually blurs together. The first is the project's source code, kept in a public repository on GitHub, where changes are proposed, reviewed and tagged when a version is released. The second is the registry publication: a packaged file uploaded to npm under the name axios and a version number, by whoever holds the right to publish. The third is what a particular organization's build actually downloads and installs, which depends on the version range it asked for and the day it asked.
On March 31 the first of these did not change. Socket observed that the new version did not appear among the project's GitHub tags, where 1.14.0 was still the latest, and concluded that it had been published outside the standard release process.1 The postmortem describes the incident as unauthorized access to the lead maintainer's account rather than a change to the project's code, and attributes it to a targeted social engineering campaign that put malware on his computer about two weeks earlier, while noting that confirmed details were not yet available.2 Anyone who read the source that day would have found nothing wrong. The problem existed only in the second and third places: the file on the registry and the copies installed from it.
That separation is the heart of the matter. Most of what we mean when we say we trust a project, its maintainers' care, its history, its reviewed code, describes the first place. Most of what a build does, it does with the third.
The code you did not choose
No one adding Axios to an application chose plain-crypto-js. It arrived because Axios, in its new version, declared that it needed it, and package managers install a package's declared dependencies automatically, along with theirs. These indirect, or transitive, dependencies routinely outnumber the packages a team selects on purpose, which means much of the code in a modern application entered through the judgment of someone else.
The attacker prepared this carefully. According to Socket, a clean copy of the legitimate crypto-js library was published under the new name as version 4.2.0, about eighteen hours before the malicious 4.2.1 appeared at 23:59 UTC on March 30, so the name had a short, harmless history before it was used.1 Version 4.2.1 included an install script, a command that npm runs automatically when the package is installed. Socket reported that the script identified the operating system, downloaded a second program suited to it from the attacker's server, and then deleted itself and swapped its own package description for a clean one, so that the installed folder afterward looked like an ordinary copy of crypto-js.1 I am describing this only at the level needed to see the point. Someone who opened the folder later would have found less than had run there.
Here is how that could reach a company, in a sequence that is illustrative rather than drawn from any reported case. A developer, or an automated build server, runs a fresh install of an application whose manifest asks for Axios at ^1.14.0. The caret means "this version or any later compatible one", and Socket noted that such a range would pick up 1.14.1 on its next install.1 The package manager resolves the newest matching version, downloads it and its new dependency, and runs the install script. On a build server, that script runs with whatever the build can reach, which often includes credentials for source control, cloud accounts and the registries the company publishes to. The application may never call a line of the new code. The damage, if any, happens during installation.
A short window is not a small one
The malicious versions were removed from npm at 03:15 UTC on March 31, and plain-crypto-js at 03:29 UTC; the postmortem puts the exposure at about three hours.2 For a library downloaded on the order of 100 million times a week, three hours is a great many installs, though how many resolved to the bad versions is not something either account establishes. The postmortem's own guidance is pointed: if a lockfile shows either version, treat that machine as compromised, return to 1.14.0 or 0.30.3, rotate every secret and credential the machine held, check network logs, and on a build server rotate whatever secrets the affected build received.2
Two features of that guidance deserve attention. First, it begins with a question many organizations find surprisingly hard: which machines installed which version, and when? Second, it does not say that removing the package fixes anything. Once code has run with access to credentials, deleting the code does not take the access back. The work after an incident like this is mostly inventory and rotation, and both go faster for an organization that already knows what it installed.
Detection, in this case, was not automatic. The postmortem lists among its lessons that the project had no automated way to detect an unauthorized publish: "Detection depended entirely on the community noticing."2 Socket's scanner flagged plain-crypto-js within minutes of its publication, a fast response that still left consumers to act on the warning.1
1 · Source
Does this version match reviewed, tagged code?
Mar 31: no tag for 1.14.1
tag comparison
2 · Publication
Who published it, and by what route?
Mar 31: compromised account, outside usual process
provenance
3 · Resolution
Which exact version did our build receive?
^1.14.0 picked up 1.14.1
lockfile · integrity hash
4 · Build
What could it run, and reach, during install?
illustrative: an install script ran
script limits · least privilege
5 · Deployed
Where did it end up?
illustrative: every machine that installed it
inventory of what was installed
Each control answers one question
Software teams have a familiar list of protections against this kind of event, and it is tempting to treat any one of them as the answer. It is more useful to ask which question each one answers, because none of them answers all of them, and Figure 2 lays them out in order.
A lockfile records the exact versions a build resolved, so that the next install uses the same ones rather than the newest in the range. It answers "which version", and it would have kept an existing project on 1.14.0 through those three hours. It does nothing when someone updates the lockfile, starts a new project, or installs without it. An integrity hash, stored in the lockfile, checks that the bytes downloaded are the bytes expected. It answers "is this the same file as last time", not "was last time safe". If the bad version is the one first recorded, the hash faithfully protects it.
Provenance connects a published package to the source and build system that produced it, typically by having an automated pipeline, rather than a person, publish with a verifiable identity. It answers "where did this come from", and it is the control most directly aimed at a stolen account. Its value depends on two conditions: that other routes to publishing are closed, and that consumers check for it. Socket reported early discussion suggesting that a long-lived publishing token had remained in use alongside the project's newer method, though it presented that as unconfirmed.1 The postmortem lists the proper adoption of automated, identity-based publishing and immutable releases among the changes the project is now making, which is a plan, not yet a record.2
Behavioral controls answer the last question: what could the code do once it ran. Disabling install scripts by default, building in environments that hold only the credentials that particular build needs, and limiting where a build server may connect all reduce what a malicious package can reach, whether or not anyone recognized it. And an inventory of what was installed where answers the question the postmortem opens with. None of these proves an artifact benign. Together they turn a familiar name into a set of answered questions.
Objections, considered
Pinning everything is its own risk. It is. A lockfile that is never updated preserves known vulnerabilities, and the reason to accept new versions is that many of them fix real problems. The February PostgreSQL release I wrote about fixed three flaws that allowed code execution. The argument is not to stop updating but to make each update a deliberate act with evidence attached, so that a new version enters because someone accepted it, not because a range allowed it.
Waiting a few days before adopting a release would have avoided this. In this case it would have, since the bad versions were gone within hours. A delay is a reasonable policy. But it is a bet that someone else will notice first, not a proof of safety. It does nothing against a malicious version that lies quiet longer than the delay, and it leaves security fixes waiting too.
Provenance and trusted publishing solve this. They address a specific risk, a stolen credential used to publish from outside the project's pipeline, and address it well. They do not help if the source itself is compromised, if the build system is, or if a legitimate maintainer publishes a harmful change. They make the origin of an artifact checkable; they do not make the artifact good.
This is an open-source problem. Commercial software is assembled from dependencies too, and its supply chain is usually less visible to the buyer rather than less exposed. The Axios maintainers published a candid, timely account of what happened, which is more than many vendors offer after an incident. The lesson applies to any software an organization acquires.
You execute a particular artifact, not a project's reputation.
Before the next build
- Name the artifact. Can you say which exact version of each dependency entered your last production build, and on which machines it was installed?
- Know what ran. Which packages in that build ran a script during installation, and did anyone decide they should?
- Count what it could reach. What credentials did the build environment hold, and how many of them did that build actually need?
- Look at new lines. When an update adds a dependency you have never used, does a person see it before it runs?
- Time the search. If a version were reported malicious tomorrow, how long would it take to find every affected environment?
Back to the manifest at the receiving dock. The clerk was right to recognize the supplier; a familiar name is real information, earned over years, and it is why the delivery was expected at all. What the familiar name cannot vouch for is a line the supplier did not write. The question to ask of that line is the same one a build should ask of every artifact: what is this, where did it come from, and what can it reach once it is inside?