Socket proactively blocks malicious open source packages in your code.
Secure your dependencies with us
Yesterday, we reported on a supply chain attack targeting Axios that introduced a malicious dependency (plain-crypto-js) into specific npm releases.
At first glance, the scope seemed contained:
- Two compromised Axios versions
- A short exposure window
- A malicious dependency that was quickly removed
Over the past 24 hours, we’re seeing many teams focus on checking their lockfiles and node_modules directories, but that only captures part of the picture, especially when tools are executed dynamically via npx.
During the exposure window, widely used tools, including CI systems, developer CLIs, build tools like Nx, and even MCP servers, could resolve the compromised version through normal dependency ranges, often without explicitly depending on Axios at all.
This incident is one of the clearest examples of dynamics that we have been warning about for years. Modern dependency resolution makes incidents like this far harder to reason about and far broader in impact than they initially appear.
This post explains how that happens, where common assumptions break down (especially around lockfiles and npx), and why the blast radius is often larger than it looks.
The Part That’s Easy to Understand#
A malicious version of Axios (1.14.1) was published to npm. That version introduced a new dependency (plain-crypto-js@4.2.1) containing a multi-stage malware payload.
Any project installing Axios during that window could pull the malicious version. If Axios was already resolved in your lockfile and installs respected that lockfile, you were likely protected. That’s where most explanations stop. During this attack, we have observed common workflows where this assumption does not hold, particularly when tools are executed dynamically via npx.
The Part That’s Not#
What’s much harder to understand is how many systems could have installed that version without ever explicitly depending on Axios.
This comes down to one detail:
Most packages do not pin exact dependency versions.
Instead, they use version ranges like:
axios: "^1.13.5"
That range means:
- Accept anything ≥ 1.13.5 and < 2.0.0
- Always resolve to the latest matching version at install time
When axios@1.14.1 was published, it became the default resolution for that range, without any code changes or alerts. It was just freshly installed.
This Was Not Isolated to One Package#
It’s easy to focus on a single example, but the pattern is widespread.
The key condition for exposure was simple:
- A package declares an Axios version range that includes
1.14.1 - A fresh install or execution happens while that version is live
- No lockfile (or no applicable lockfile) constrains resolution
- A non-deterministic install occurs (e.g.
npm installwithout a lockfile, or installing a CLI outside a project context)
Under those conditions, the package manager will select the malicious version by default.
During the exposure window, a large number of widely used tools met these conditions.
Note: None of the packages listed below were compromised, and their dependency declarations are typical and appropriate for the npm ecosystem.
Using semver ranges is a deliberate design tradeoff that enables compatibility and deduplication across the dependency graph. These examples illustrate how that same mechanism can expand the blast radius of a short-lived malicious release.
The examples below are not exhaustive. They illustrate how common this pattern is across CI tooling, CLIs, and frameworks:
- CI tooling (
@datadog/datadog-ci)
At the time of the attack it declaredaxios: "^1.13.5"across multiple sub-packages, such as @datadog/datadog-ci-plugin-coverage.
Any fresh install ornpxexecution during the window resolves to1.14.1. - Observability / instrumentation (e.g. Honeycomb OpenTelemetry tooling)
Uses ranges like^1.1.3for Axios.
Often installed dynamically in CI or runtime environments without lockfile protection. - Contract testing frameworks (Pact)
Declares Axios with a compatible range (^1.12.2).
Commonly installed in CI pipelines where fresh installs are routine. - Developer CLIs (
@aws-amplify/cli, others)
Frequently run vianpxor installed globally, declares Axios range (^1.11.0)
This triggers real-time dependency resolution against the registry rather than a project lockfile. - Static site tooling (
Gatsby)
Declares a broad Axios range (^1.6.4).
New project scaffolding (npx gatsby new) or fresh installs would resolve to the latest matching version. - Build systems (
Nx)
Depend on Axios transitively with ranges like^1.2.0.
Any environment rebuilding dependencies without a pinned lockfile could pull in the compromised version. - CI utilities (
wait-on)
Directly depends on Axios with^1.13.5.
Commonly used in ephemeral CI jobs where dependencies are installed from scratch.
In all of these cases:
- The package itself was not compromised
- The dependency declaration was valid and typical
- The risk was introduced entirely at resolution time
In one observable case, a CI pipeline running a CLI via npx pulled in the malicious dependency through a transitive Axios range, resulting in observable command-and-control traffic during a build step.
This is what makes the blast radius unintuitive.
The question is not:
“Do you depend on Axios?”
It is:
“Did anything you executed resolve Axios during that window?”
Looking beyond traditional CI and CLI tooling, similar patterns show up in MCP servers and agent-oriented packages.
In a sample of MCP servers from a public leaderboard, a significant portion included Axios ranges that would have resolved to the compromised version during the exposure window.
A few examples:
- task-master-ai@0.43.1
Declares Axios transitively viapipenet@1.4.0with range^1.7.3 - n8n@2.14.2
Pins Axios directly to1.13.5, but multiple transitive dependencies — including@1password/connect,ibm-cloud-sdk-core,snowflake-sdk, and others — use ranges such as^1.10.0,^1.13.5, and^1.6.2, which would resolve to1.14.1during the attack window - exa-mcp-server@3.2.0
Uses Axios with range^1.13.6, both directly and viaagnost@0.1.10 - claude-flow@3.5.48
Pulls Axios viaagentic-flow@2.0.7andpipenet@1.4.0, with range^1.12.2 - firecrawl-mcp@3.11.0
Depends on Axios via@mendable/firecrawl-js@4.15.2with range^1.13.5 - mcp-atlassian@2.1.0
Declares Axios directly with range^1.11.0
Exposure Across Widely Used Production SDKs#
To understand how far this pattern extends, we looked at a sample of widely used SDKs and infrastructure packages commonly found in production environments.
They are core integrations used across CI systems, backend services, and developer workflows. Across this sample, the same pattern holds: broad semver ranges and transitive usage.
A few examples:
- @1password/connect@1.4.2
Declares Axios with range^1.10.0 - @sendgrid/mail@8.1.6
Pulls Axios via@sendgrid/client@8.1.6with range^1.12.0 - @slack/web-api@7.15.0
Declares Axios directly with range^1.13.5 - @slack/bolt@4.6.0
Uses Axios both directly and via@slack/web-api, with range^1.12.0 - snowflake-sdk@2.3.6
Declares Axios with range^1.13.4 - ibm-cloud-sdk-core@5.4.9
Declares Axios with range^1.13.5 - @ibm-cloud/cloudant@0.12.16
Pulls Axios viaibm-cloud-sdk-core - @sap-cloud-sdk/core@1.54.2
Uses Axios via@sap/xssec, with mixed ranges including^1.6.x - contentful@11.12.0
Declares Axios with range^1.13.5 - contentful-management@12.2.0
Declares Axios with range^1.13.5 - square@44.0.1
Pulls Axios via@apimatic/axios-client-adapterwith range^1.8.4 - plaid@41.4.0
Declares Axios with range^1.7.4 - twilio@5.13.1
Declares Axios with range^1.13.5 - postmark@4.0.7
Declares Axios with range^1.13.5 - @crowdin/crowdin-api-client@1.55.0
Declares Axios with range^1
Why the Blast Radius Is Larger Than It Looks#
While this post focuses on the Axios compromise, this pattern is not unique. Recent incidents, including the hijacking of the widely used is package and maintainer compromises affecting packages in the eslint and prettier ecosystems, in 2025, have shown how malicious versions can be introduced and propagate quickly through dependency graphs. Any widely used package with broad semver ranges and transitive adoption can exhibit the same behavior under the right conditions.
There are three compounding factors that make incidents like this difficult to scope.
Version Ranges Are the Default
Most packages intentionally avoid pinning dependencies. This is intentional. Pinning dependencies in published packages would force every consumer to install that exact version, leading to duplication, and version conflicts across the dependency tree.
Using semver ranges allows package managers to share compatible versions across dependencies, reducing install size and avoiding conflicts. This is a deliberate ecosystem tradeoff, not negligence.
This keeps ecosystems flexible and avoids and bloat, but it also means dependency graphs are constantly shifting based on what is available in the registry.
Lockfiles Only Work Sometimes
Lockfiles are often presented as the solution, but they only protect you under specific conditions:
- You have a lockfile
- You use install modes that respect it
- You are not introducing new dependencies
Many real-world workflows fall outside those conditions:
- Running tools via
npxor global installs, especially in CI environments - Fresh environments or ephemeral builds
- Projects without committed lockfiles
Even when a lockfile exists, it does not apply to everything you execute.
Darcy Clarke, founder of vlt and former npm Engineering Manager of Community & Open Source, explained it this way:
When you're installing or executing something new, the dependency graph has to be recalculated. That’s how package managers work. Lockfiles don’t prevent net-new installs when updating/adding new dependencies. That’s the point.vlt takes the approach that all third-party packages are untrusted & gates the execution of lifecycle scripts with the use of Socket's insights. The minute Socket flagged the malicious versions of
Axios&plain-crypto-js- if you were usingvlt exec-local- you were protected from this exploit.
Lockfiles make existing installs deterministic. They do not make new installs safe.
In most cases, well-configured CI workflows that rely on committed lockfiles and deterministic installs (e.g. npm ci) are not affected by this class of issue.
However, this protection breaks down when new dependency resolution is introduced, such as when adding or updating dependencies, executing tools dynamically, or automatically merging dependency update PRs.
This dynamic is amplified in environments where dependency updates are automated, including with bots or AI-driven workflows, where new dependency resolution can be introduced continuously and without direct human review.
Execution Patterns Like npx Change the Model
CI install workflows are generally safe, but using npx in CI is not necessarily safe. This introduces another layer of complexity.
These tools are often:
- Not part of your project dependencies
- Not represented in your lockfile
- Installed on demand at execution time
When using npx, a locally installed version will be used if available. Otherwise, the package is fetched from the registry and its dependencies are resolved at execution time, which can introduce risk if a malicious version is briefly available.
That means every execution can trigger fresh dependency resolution against the current state of the registry. You are essentially trusting whatever versions exist at that exact moment.
Even explicitly pinning a version at execution time does not fully solve this.
For example, running npx foo@1.2.3 ensures that specific version of foo is used, but its dependencies are still resolved dynamically based on their declared version ranges. Those transitive dependencies are not pinned and will be resolved against whatever is available in the registry at that moment.
This is compounded by the fact that npm does not distribute lockfiles with published packages. Lockfiles are intentionally excluded from registry artifacts (locking transitive dependencies in a published package would cause version conflicts across the wider dependency tree and prevent deduplication), which means there is no way for package authors to enforce a fully pinned dependency graph for consumers at install time.
The Hardest Part of Figuring Out If You Were Affected#
This is where things become genuinely difficult. After the malicious version is removed:
- npm no longer serves it
- dependency trees resolve differently
- reinstalling produces a clean result
So you might check your project today and see nothing unusual. That does not mean you were not exposed.
Reconstructing what happened during the window would require a complete snapshot of the ecosystem at that point in time, which most environments do not retain. It requires:
- Full lockfiles from that exact point in time
- Complete build logs showing resolved versions
- Artifact snapshots of
node_modules - Network telemetry capturing outbound requests
Most environments do not retain all of this. And even when they do, it may not be complete enough to answer definitively.
There are additional complications:
- You may see the malicious dependency (
plain-crypto-js) without ever seeing Axios itself. - Dependencies may be vendored or bundled inside published packages, meaning the malicious code is not visible as a direct dependency and may not appear in standard dependency trees.
- You may have executed it transiently in CI without persisting it anywhere
- You cannot re-run installs to reproduce the state, because the registry has already changed.
- npm does not retain a public, queryable history of all dependency graphs as they existed at a point in time.
Even with perfect logs, you are often reconstructing behavior indirectly. In many cases, the best you can do is infer exposure based on timing and partial evidence.
In other words:
The absence of evidence after the fact is not strong evidence of absence.
The Core Problem: Time-Dependent Dependency Resolution#
At the center of this is a fundamental property of the ecosystem:
Dependency resolution is time-dependent.
Two identical commands run hours apart can produce different results:
- Before the attack → safe dependency tree
- During the attack → compromised dependency tree
- After removal → safe again
Nothing in your code changed. The only thing that changed is the registry.
What Actually Helps (and What Doesn’t Fully Solve It)#
There is no clean, universally accepted solution here. While there are some mitigations that reduce risk in scenarios like this, none of them fully eliminate it.
- Lockfiles
Provide strong protection for existing dependencies, but do not apply to new installs,npxexecutions, or dynamic tooling in CI. - Pinned dependencies
Reduce exposure to unexpected updates, but are not widely used in libraries and come with tradeoffs around duplication and maintenance. - Deterministic install workflows (
npm ci,pnpm install --frozen-lockfile)
Help ensure consistency, but only when a lockfile already exists and is respected. - Avoiding ad-hoc execution (
npx, global installs)
Reduces risk, but conflicts with common developer workflows and tooling expectations. - Short-lived exposure windows
Make reactive defenses difficult. By the time an issue is identified, the vulnerable version may already be gone. - Minimum publish age / cooldown windows (where supported)
Some package managers can delay installs of newly published versions, reducing exposure to short-lived malicious releases. For example, pnpm supports aminimumReleaseAgesetting. Support is not standardized across ecosystems, and behavior can often be overridden via configuration (e.g..npmrc), which limits its reliability.
The important point is not that these controls are ineffective. It’s that they are context-dependent.
They work well in controlled environments, but break down in exactly the kinds of workflows that are now common across modern development and CI systems.
These tradeoffs are well understood by maintainers. They are also exactly what attackers are beginning to exploit.
How to De-Risk Run Time Dependency Resolution#
For CI workflows
Preparation
- Create a root
package.json(or use a dedicated npm workspace) sfw npm install <package>@versionthe desired packages- a
package-lock.jsonwill be generated
- a
- edit your
package.jsonand pin every version to the specific one you are using- Be aware that
npm installdefaults to^ranges.
- Be aware that
- If you're not already using Socket's Firewall, this is a good time to run
socket scanto verify safety. - Commit and push your changes.
Execution
- In every CI pipeline, make sure you run
npm ciwhere thepackage-lock.jsonwas committed.- DO NOT run
npm install- this will override yourpackage-lock.jsonand pull in new package versions (within applicable ranges).
- DO NOT run
- Change all
npxinvocations in your CI pipeline to:npx --no --offline--nowill ensure not to install a package if it's not present in the local project dependencies--offlineForces full offline mode. Any packages not locally cached will result in an error.
- Depending on your setup, you might want to specifically point to a workspace where the CI relevant packages are installed:
npx --no --offline --include-workspace-root --workspace /path/to/ci-workspace
For local MCP packages
Preparation
- Create a dedicated directory to install and manage your dependencies, (e.g
cd $HOME/mcp)create a newpackage.json(npm init --yes) - Follow the same preparation steps from above
Usage
In your your AI Client configuration, for each mcp server that uses npx, ensure the following:
- Add the following arguments:
--include-workspace-root --workspace $HOME/mcp --no --offline, to everynpxinvocation. - For extra safety, never use
latest! always specify the in the version of the package you want the AI agent to execute withnpx.
Full example:
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"--include-workspace-root",
"--workspace $HOME/mcp",
"--no",
"--offline",
"@playwright/mcp@v0.0.70"
]
}
}
}
Note:
You can also set npm_config_yes=false in a .npmrc or set NPM_CONFIG_YES=false as an env var to instead of using --no every time.
Where Install-Time Controls Fit#
One of the few places this type of attack can be reliably stopped is at install time.
In this incident, the risk existed only while the malicious version was live on the registry and being resolved by package managers. Once installed, the payload executed immediately. After removal, the version was no longer available to analyze or reproduce.
That makes traditional approaches less effective:
- Static dependency analysis may miss short-lived exposure windows
- Lockfiles only protect previously resolved dependencies
- Post-install detection happens after execution
Controls that operate at install time address a different part of the problem.
For example, Socket Firewall intercepts package requests as they are made to the registry and checks them against known malicious packages and policy rules, blocking those that have already been identified as unsafe before they are downloaded or executed. (This tool is free, by the way, and we also offer enterprise support for additional features.)
This does not eliminate the underlying issues with dependency resolution, but it changes the outcome in scenarios like this one:
- If a malicious version is introduced into a valid semver range
- And resolved during a fresh install or
npxexecution - It can be blocked before the install completes
This is one of the few control points where short-lived supply chain attacks can be stopped before execution. This helps in scenarios like this, but it doesn’t change the underlying complexity.
A package as widely used as Axios being compromised shows how difficult it is to reason about exposure in a modern JavaScript environment.
- You may have been affected without knowing it.
- You may not be able to prove whether you were affected.
- The window of risk may have been measured in minutes.
This is not a failure of one project or one team. It is a property of how dependency resolution in the ecosystem works today. And it is a problem that does not yet have a simple answer.