On March 31, 2026, the axios npm package was compromised via a hijacked maintainer account. Two versions, 1.14.1 and 0.30.4, were weaponised with a malicious phantom dependency called plain-crypto-js. It functions as a Remote Access Trojan (RAT) that executes during the postinstall phase and silently exfiltrates environment variables: AWS keys, GitHub tokens, database credentials, and anything present in your .env at install time.
The attack window was approximately 3 hours (00:21 to 03:29 UTC) before the packages were unpublished. A single CI run during that window is sufficient exposure.
This post documents the forensic audit and remediation steps performed on a Next.js production stack immediately after the incident.
Why This Happened: The SemVer Caret Problem
Most projects define axios like this:
"axios": "^1.7.9"
The caret (^) permits any compatible minor or patch release. It means npm install can silently resolve to a newly published 1.14.1 if it satisfies the range. No prompt, no warning, no diff you would notice without inspecting the lockfile.
This is the core tension in SemVer: convenience versus determinism.
The Fix: Pin to the Golden Version
"axios": "1.14.0"
No caret. No tilde. Exact version.
Why 1.14.0 specifically? It is the last clean release before the March 31 hijack. It also includes the patch for CVE-2025-27152, an SSRF vulnerability fixed in 1.8.2, so you are not trading one vulnerability for another.
Versions to avoid:
1.14.1— compromised (RAT injected)0.30.4— compromised (RAT injected)
Use --save-exact to prevent npm from re-adding the caret on install:
npm install axios@1.14.0 --save-exact
What If Axios Is a Transitive Dependency?
If axios is not a direct dependency in your project but a dependency of another package, pinning it in package.json may not be sufficient. npm can still resolve a different version deeper in the tree.
Use the overrides field in package.json to force the exact version project-wide, regardless of what upstream packages request:
"overrides": {
"axios": "1.14.0"
}
This applies to npm 8.3 and above. For yarn, the equivalent is resolutions. For pnpm, use overrides under the pnpm key in package.json.
Forensic Audit: Were You Hit?
Even if package.json looks correct, package-lock.json may have resolved the malicious metadata during the attack window if npm install ran between 00:21 and 03:29 UTC.
The following checks were performed on the production server via SSH.
1. Lockfile Entropy Check
Run both grep patterns. The first checks for the malicious dependency name and version in the axios context. The second checks for the raw version string regardless of JSON nesting depth, which is relevant in package-lock.json v2 and v3 formats:
# Check 1: Malicious dependency name and axios version context
grep -E "plain-crypto-js|axios.*(1\.14\.1|0\.30\.4)" package-lock.json
# Check 2: Raw version string, catches nested lockfile structures
grep -E "\"version\": \"(1\.14\.1|0\.30\.4)\"" package-lock.json
Critical gotcha: run this from the correct directory. A "file not found" error is not a clean result. It means grep never inspected anything. The correct workflow:
user@server:~$ grep -E "plain-crypto-js|axios.*(1\.14\.1|0\.30\.4)" package-lock.json
grep: package-lock.json: No such file or directory # Wrong directory. Not a clean result.
user@server:~$ cd project
user@server:~/project$ grep -E "plain-crypto-js|axios.*(1\.14\.1|0\.30\.4)" package-lock.json
grep: package-lock.json: No such file or directory # Still wrong. Keep navigating.
user@server:~/project$ cd frontend
user@server:~/project/frontend$ grep -E "plain-crypto-js|axios.*(1\.14\.1|0\.30\.4)" package-lock.json
# No output = clean
No output from the final command means no malicious indicators found in the lockfile.
2. Filesystem Dropper Verification
The RAT executes via a postinstall script registered by plain-crypto-js. If the directory exists in node_modules, the dropper has already run:
user@server:~/project/frontend$ ls node_modules/plain-crypto-js 2>/dev/null \
&& echo "CRITICAL: Malicious package found!" \
|| echo "Clean: No dropper found."
Clean: No dropper found.
If either check returns a positive result: rotate all secrets immediately. This includes credentials you might overlook under pressure:
Application secrets: database passwords, third-party API keys, cloud provider credentials
CI/CD pipeline secrets:
NPM_TOKEN,VERCEL_TOKEN,AWS_ACCESS_KEY_ID, and any other tokens injected as environment variables during the build. These are present in the environment at npm install time and are a primary target of this class of attack.
Treat every credential present in the build environment during the compromised install as leaked. Review your cloud provider audit logs for anomalous API calls originating from the build environment before doing anything else.
Automating Prevention in CI/CD
Pinning resolves the immediate exposure. The following three controls prevent recurrence.
A. Lockfile Integrity Enforcement
Use npm ci instead of npm install in your build pipeline. It installs exactly what is recorded in package-lock.json and fails if the two files are out of sync. No silent resolution, no opportunistic upgrades.
For Docker-based builds, this belongs in the Dockerfile:
RUN npm ci
B. Dependency Scan as a Kill-Switch
Add a pre-build security stage that greps the lockfile for known-malicious strings. If an upstream package is hijacked again, deployment is blocked before a single container is built.
GitLab CI example (shell executor, Docker available on runner):
stages:
- security
- deploy
dependency_scan:
stage: security
tags:
- oci-runner
- production
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
script:
- echo "Scanning for malicious dependency versions..."
- |
if grep -E "(\"axios\".*\"(1\.14\.1|0\.30\.4)\"|plain-crypto-js)" \
"$CI_PROJECT_DIR/frontend/package-lock.json"; then
echo "SECURITY ALERT: Malicious dependency detected. Blocking deployment."
exit 1
fi
- echo "Malicious version scan passed."
- echo "Running npm audit..."
- docker run --rm
-v "$CI_PROJECT_DIR/frontend:/app"
-w /app
node:20-alpine
npm audit --audit-level=high
Implementation notes:
Do not use
-rin the grep. That flag recurses into directories and is incorrect when targeting a specific file.Use
$CI_PROJECT_DIRfor absolute paths. Relative paths are fragile if any earlier step changes the working directory. This is the same "wrong directory" failure mode demonstrated in the manual audit above.npm auditruns in a throwaway container. If the shell runner does not have Node installed directly, a temporarynode:20-alpinecontainer handles it without modifying the runner host.
GitHub Actions equivalent:
- name: Audit for Malicious Axios Versions
run: |
if grep -E "plain-crypto-js|axios.*(1\.14\.1|0\.30\.4)" package-lock.json; then
echo "Security Alert: Malicious dependency detected!"
exit 1
fi
- name: npm audit
run: npm audit --audit-level=high
C. npm audit with Failure Thresholds
npm audit --audit-level=high causes CI to fail on any vulnerability rated High or Critical. It covers a broader class of supply chain issues beyond this specific incident and adds minimal overhead to the pipeline.
npm audit --audit-level=high
One important caveat: for a zero-day or freshly published hijack, npm audit may not flag the package until a security advisory has been formally ingested into the npm advisory database. During the initial hours of an active attack, the manual grep stage in section B is the more reliable immediate control. The two approaches are complementary, not interchangeable.
D. Egress Restriction on Build Runners
As a systemic long-term control, restrict outbound network traffic from your build environment to a known allowlist of domains. A RAT cannot exfiltrate environment variables if the build server is blocked from making outbound requests to unknown IPs or domains.
Most cloud providers offer security groups or firewall rules at the instance level. For OCI, this is configured via the VCN's security list or network security groups. The build runner should be permitted to reach package registries, your container registry, and your deployment target and nothing else by default.
One layer deeper: blocking standard HTTP/S egress is not sufficient against advanced RATs. DNS exfiltration is a documented technique where data is encoded and tunnelled out via DNS queries, which most firewall rules pass freely. If your threat model warrants it, implement DNS filtering and logging on build runners either via a resolver that blocks non-allowlisted domains, or a logging layer that surfaces anomalous query volumes. This is the logical next control once HTTP/S egress is locked down.
Worth knowing: If supply chain risk is a recurring concern for your stack, look into Socket or Snyk. Both offer malicious package detection that goes beyond standard vulnerability scanning by analysing package behaviour rather than just matching against known CVEs. npm audit tells you about published advisories. These tools flag suspicious patterns before an advisory exists. Both have free tiers suitable for open source projects and solo developers; private commercial repositories require a paid plan.
Summary
| Action | Priority |
|---|---|
Pin axios to 1.14.0(no caret) |
Immediate |
Add overrides in package.json if axios is a transitive dependency |
Immediate |
Grep lockfile for plain-crypto-js and bad versions, from the correct directory |
Immediate |
Check whether node_modules/plain-crypto-js exists |
Immediate |
| Rotate all secrets if either check is positive | Immediate |
Switch CI builds to npm ci
|
This sprint |
| Add dependency scan stage to pipeline | This sprint |
Add npm audit --audit-level=high to CI |
This sprint |
| Restrict build runner egress to known domains | Next sprint |
| Implement DNS filtering and logging on build runners | Next sprint |
Supply chain attacks exploit the trust relationship between developers and the package registry. The plain-crypto-js incident demonstrates that a single compromised maintainer account is sufficient to poison any project that does not lock its dependencies with exact versions.
Pin your versions. Audit your lockfiles. If your build logs show npm install activity on March 31, rotate credentials first and investigate second.
Cover photo by Clint Patterson on Unsplash
Top comments (0)