I woke up this morning to a Slack message from our security lead: “axios got owned on npm.” I thought it was a joke. Axios has 60 million weekly downloads. It is one of those packages you just assume is safe because everyone uses it.

It was not a joke.

What Actually Happened

Two malicious versions hit npm overnight: [email protected] and [email protected]. The attacker compromised a lead maintainer’s npm credentials, changed the account email to a ProtonMail address, and published manually via the npm CLI. No pull request. No CI run. No code review. Just a npm publish from a stolen account.

The clever part: the malicious code is not in axios itself. Both versions inject a dependency called [email protected] that runs a postinstall script. That script is a cross-platform RAT dropper. It phones home to a C2 server, downloads a second-stage payload for your OS, executes it, then deletes itself and overwrites its own package.json with a clean version. If you inspect node_modules after the fact, everything looks normal.

The attacker staged the fake package 18 hours in advance with a clean v4.2.0 first, so it would not trigger “new package” alarms. Both axios branches were hit within 39 minutes. This was planned.

The First Thing I Did: Check If We Were Hit

Before panicking, I needed to know if any of our projects had pulled the bad versions. Here is what I ran across every repo:

# Check lockfiles for the compromised versions
grep -r "[email protected]\|[email protected]\|plain-crypto-js" */package-lock.json */yarn.lock */pnpm-lock.yaml 2>/dev/null

We were clean. Our lockfiles pinned axios at 1.7.9 across most projects and 1.14.0 in one newer service. But “clean in the lockfile” does not mean “clean everywhere.” I also checked CI artifacts:

# Check if any recent CI runs installed the bad version
# We log npm ls output as a build artifact
for f in /tmp/ci-artifacts/*/npm-ls.txt; do
  grep -l "[email protected]\|[email protected]\|plain-crypto-js" "$f"
done

Nothing. But if you do find it, the StepSecurity advisory is clear: assume the machine is compromised. Rotate every credential that was accessible from that environment.

Why Lockfiles Saved Us (This Time)

We pin exact versions and commit lockfiles. Always. This is the one practice that saved us today. If you are running npm install without a lockfile, or if your lockfile is not committed, you are gambling every single build.

Here is the thing though: lockfiles only protect you if your CI actually uses them:

# WRONG - this can update the lockfile and pull new versions
npm install

# RIGHT - this respects the lockfile exactly
npm ci

I searched our CI configs and found two repos that were still using npm install in their Dockerfiles. Fixed those immediately:

# Before (dangerous)
COPY package*.json ./
RUN npm install --production

# After (locked)
COPY package*.json package-lock.json ./
RUN npm ci --production

The difference matters. npm ci will fail if the lockfile is out of sync with package.json instead of silently updating it. That failure is a feature.

Locking Down postinstall Scripts

The axios attack relies entirely on postinstall scripts. The malicious plain-crypto-js package runs node setup.js during installation, and that is where the payload executes. You can disable this globally:

# In your .npmrc (project level or CI level)
ignore-scripts=true

The problem is that some legitimate packages need postinstall scripts. esbuild, sharp, node-sass, they all download platform-specific binaries during install. So blanket-disabling scripts breaks things.

What I did instead was set up an allowlist approach:

# .npmrc
ignore-scripts=true

# Then explicitly run scripts for packages that need them
# In your CI pipeline:
npm ci --ignore-scripts
npx --yes node-gyp rebuild  # only if you need native modules

For packages like esbuild that bundle platform binaries, check if they offer pre-built packages you can install directly instead of relying on postinstall:

# Instead of letting esbuild's postinstall download the binary
npm i --save-exact @esbuild/linux-x64

Adding npm audit to the Pipeline

We already ran npm audit but only as a weekly scheduled job. After today, it runs on every build:

# .github/workflows/ci.yml
- name: Security audit
  run: |
    npm audit --audit-level=high
    # Also check for known compromised packages
    npx is-my-node-vulnerable    

I also added a step that checks if any dependency was published in the last 24 hours. New publishes right before your build should raise an eyebrow:

#!/bin/bash
# check-fresh-deps.sh
set -euo pipefail

npm ls --all --json 2>/dev/null | jq -r '
  [.. | .resolved? // empty] | unique[]
' | while read -r url; do
  pkg=$(echo "$url" | grep -oP '(?<=/)(@[^/]+/[^/]+|[^/]+)(?=/-/)')
  version=$(echo "$url" | grep -oP '(?<=-)\d+\.\d+\.\d+(?=\.tgz)')
  if [ -n "$pkg" ] && [ -n "$version" ]; then
    pub_time=$(npm view "$pkg@$version" time --json 2>/dev/null | jq -r ".\"$version\"" 2>/dev/null)
    if [ -n "$pub_time" ] && [ "$pub_time" != "null" ]; then
      pub_epoch=$(date -d "$pub_time" +%s 2>/dev/null || echo 0)
      now_epoch=$(date +%s)
      age_hours=$(( (now_epoch - pub_epoch) / 3600 ))
      if [ "$age_hours" -lt 24 ]; then
        echo "WARNING: $pkg@$version was published $age_hours hours ago"
      fi
    fi
  fi
done

This is not perfect, but it would have flagged [email protected] immediately.

Network Egress Controls in CI

StepSecurity’s Harden-Runner caught this attack because it monitors outbound network connections from CI runners. The RAT tried to call home to sfrclak.com:8000, which was immediately flagged as anomalous.

If you are on GitHub Actions, adding Harden-Runner is a one-liner:

- uses: step-security/harden-runner@v2
  with:
    egress-policy: audit  # start with audit, move to block
    allowed-endpoints: >
      registry.npmjs.org:443
      github.com:443
      api.github.com:443      

For self-hosted runners or other CI systems, you can achieve something similar with iptables:

# On your CI runner, restrict outbound to known registries
iptables -A OUTPUT -p tcp -d registry.npmjs.org --dport 443 -j ACCEPT
iptables -A OUTPUT -p tcp -d github.com --dport 443 -j ACCEPT
iptables -A OUTPUT -p tcp -d api.github.com --dport 443 -j ACCEPT
# ... add your other legitimate endpoints
iptables -A OUTPUT -p tcp --dport 443 -j LOG --log-prefix "BLOCKED: "
iptables -A OUTPUT -p tcp --dport 443 -j DROP

This is something I had on my todo list for months. Today was the push I needed. We now have egress policies on all CI runners.

Pinning Dependencies by Hash

Beyond lockfiles, you can verify package integrity with npm integrity checks. Modern lockfiles already include SHA-512 hashes:

"node_modules/axios": {
  "version": "1.14.0",
  "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
  "integrity": "sha512-R3tN..."
}

npm ci verifies these hashes automatically. If someone publishes a different tarball for the same version (which npm does not allow, but other registries might), the hash check will fail.

For extra paranoia, consider using a private registry that proxies npm and caches packages:

# Verdaccio config for proxying npm with caching
# verdaccio/config.yaml
uplinks:
  npmjs:
    url: https://registry.npmjs.org/
    cache: true
packages:
  '**':
    access: $all
    proxy: npmjs

This gives you a local cache that does not update automatically. Even if a malicious version hits npm, your builds keep pulling from the cached clean version until you explicitly update.

What I Changed Today

Here is the full list of what I did across our repos in about 3 hours:

  1. Audited every lockfile for compromised axios versions
  2. Replaced every npm install in CI with npm ci
  3. Added ignore-scripts=true to .npmrc in all projects, with explicit script runs for packages that need them
  4. Added npm audit --audit-level=high as a blocking CI step
  5. Deployed the fresh-dependency check script
  6. Set up egress policies on our GitHub Actions runners
  7. Documented the incident in our security runbook

None of this is complicated. Most of it should have been done already. It took an attack on one of npm’s most popular packages to make me actually do it.

The Bigger Problem

The npm ecosystem has a fundamental trust problem. Anyone with a maintainer’s credentials can publish anything, and millions of CI pipelines will execute whatever code is in those packages without question. There is no mandatory code review. There is no mandatory 2FA for publishing (though npm has been pushing for it). There is no delay between publish and availability.

The axios attacker knew exactly how to exploit this. Stage a clean package first. Wait. Publish the poison. Let CI pipelines around the world run your code. Delete the traces.

Until the ecosystem solves this at the registry level, the defense is on us. Lock your dependencies. Verify your hashes. Control your network egress. Do not trust postinstall scripts.

If you have not checked your projects yet, do it now. The compromised versions are [email protected] and [email protected]. If either shows up anywhere in your dependency tree, treat that machine as compromised.