npm supply chain attack 2025: ‘Shai-Hulud’ CI fixes
Developers are on the front line of the npm supply chain attack 2025 (the “Shai-Hulud” worm) that targets CI secrets and account tokens. This developer-first incident-response playbook shows exactly how to contain it in hours—not weeks—by enforcing trusted publishing, granular tokens, provenance checks, and safe build defaults in your CI/CD.
TL;DR: Rotate npm/GitHub tokens, mandate 2FA and trusted publishing, disable
postinstall
, pin everything, verify provenance on publish/consume, and freeze dependencies until attested.
1) Incident overview (what’s happening)
- Malicious npm packages are stealing tokens and environment secrets via install hooks and obfuscated payloads.
- Attackers pivot into CI, reuse credentials, and publish trojan updates to downstream packages.
- Industry guidance focuses on 2FA, scoped tokens, trusted publishing, and provenance/attestation; ecosystem maintainers are tightening defaults accordingly.
2) Immediate actions (do these in the next 1–3 hours)
A. Rotate and scope all tokens
# List npm tokens (run from a secure workstation)
npm token list
# Revoke any unused or suspicious tokens
npm token revoke <token-id>
# Create least-privilege tokens (read-only or publish-only)
npm token create --read-only
# or for publish from CI (scope to your org/scope)
npm token create --read-write --cidr=<office-egr-ip>/32
GitHub fine-grained PATs (if you still use them) should be replaced with OIDC-based short-lived tokens in Actions.
B. Mandate 2FA—and enforce it for writes
# Require 2FA for login AND writes on your npm account
npm profile enable-2fa auth-and-writes
C. Kill install-time malware: disable scripts everywhere
# Per-repo: CI safe install
npm ci --ignore-scripts
# Global dev setting (propagates to CI unless overridden)
npm config set ignore-scripts true
D. Pin to the lockfile and turn off semver drift
# Ensure installs use EXACT versions from lockfile
npm ci
# Prevent new carets/tilde from being added
npm config set save-prefix ""
E. Freeze dependencies at the org boundary
- Fail PRs that change
package-lock.json
without review. - Temporarily block dependency updates until packages are re-verified/attested (see “Staged unfreeze”).
Pentest Testing Corp — Free Website Vulnerability Scanner Webpage
3) CI defenses that actually stop “Shai-Hulud”
3.1 Trusted publishing + provenance on publish
Use GitHub Actions OIDC with npm provenance so consumers can verify the package was built from your repo/ref.
# .github/workflows/publish.yml
name: npm publish (trusted + provenance)
on:
push:
tags: ['v*.*.*']
permissions:
contents: read
id-token: write # OIDC for provenance
packages: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- run: npm ci --ignore-scripts
- run: npm test --if-present
- name: Publish with provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
NPM_CONFIG_PROVENANCE: 'true' # ensure --provenance
run: npm publish --access public --provenance
3.2 Block unsigned/untrusted artifacts in consumer builds
Verify that installed tarballs match your lockfile integrity
and (optionally) that packages come from an allow-listed publisher.
# Step 1: enforce lockfile integrity
npm ci --ignore-scripts --audit=false
node .ci/verify-lock-integrity.js
// .ci/verify-lock-integrity.js
// Fails the build if any resolved package domain isn't the official registry
// or if integrity fields are missing/mismatched.
const fs = require('fs');
const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'));
function walk(deps, issues=[]) {
for (const [name, d] of Object.entries(deps || {})) {
if (!d.integrity || !/^sha512-/.test(d.integrity)) {
issues.push(`Missing/weak integrity for ${name}@${d.version}`);
}
if (d.resolved && !/^https:\/\/registry\.npmjs\.org\//.test(d.resolved)) {
issues.push(`Non-standard registry for ${name}@${d.version}: ${d.resolved}`);
}
walk(d.dependencies, issues);
}
return issues;
}
const problems = walk(lock.packages || lock.dependencies);
if (problems.length) {
console.error('Integrity verification failed:\n' + problems.join('\n'));
process.exit(1);
}
console.log('Integrity OK');
3.3 Allowlist maintainers (hard mode)
Keep a JSON allowlist of expected maintainer emails for critical packages. Break the build if the maintainer set changes.
// .ci/maintainers-allowlist.json
{
"react": ["[email protected]"],
"express": ["[email protected]", "[email protected]"]
}
# Simple verifier (requires network): fail if npm view maintainers differs
node .ci/check-maintainers.js
// .ci/check-maintainers.js
const { execSync } = require('child_process');
const fs = require('fs');
const allow = JSON.parse(fs.readFileSync('.ci/maintainers-allowlist.json','utf8'));
let bad = [];
for (const pkg of Object.keys(allow)) {
const out = execSync(`npm view ${pkg} maintainers --json`, { stdio: ['ignore','pipe','inherit']}).toString();
const maintainers = JSON.parse(out).map(m => (m.email||'').toLowerCase()).filter(Boolean);
const expected = allow[pkg].map(e => e.toLowerCase());
const missing = expected.filter(e => !maintainers.includes(e));
if (missing.length) bad.push(`${pkg}: expected ${expected} but got ${maintainers}`);
}
if (bad.length) { console.error('Maintainer allowlist violation:\n' + bad.join('\n')); process.exit(2); }
console.log('Maintainers OK');
3.4 Generate an SBOM and gate builds on known-exploited vulns
# CycloneDX SBOM for npm
npx @cyclonedx/cyclonedx-npm --output sbom.json
# Example: block on a denylist (quick start)
node .ci/kev-gate.js sbom.json .ci/kev.json
// .ci/kev-gate.js (toy example)
// Fails if SBOM contains packages on your KEV/denylist
const fs = require('fs');
const sbom = JSON.parse(fs.readFileSync(process.argv[2],'utf8'));
const kev = new Set(JSON.parse(fs.readFileSync(process.argv[3],'utf8')).packages);
const hits = (sbom.components||[]).filter(c => kev.has(`${c.name}@${c.version}`));
if (hits.length) { console.error('KEV block:', hits.map(h=>h.name+'@'+h.version).join(', ')); process.exit(3); }
console.log('KEV clean');
3.5 Emergency mirror & dependency freeze
Run an internal Verdaccio mirror and point CI there while you validate upstream packages.
# Example: switch to your read-only mirror
npm config set registry https://npm-mirror.example.com/
# Lock production builds to the mirror during incident response
export NPM_CONFIG_REGISTRY=https://npm-mirror.example.com/
npm ci --ignore-scripts
4) Monitor & recover (next 1–3 days)
Scan CI for malicious steps and secrets leakage
# Enumerate workflows & jobs
gh api repos/:owner/:repo/actions/workflows | jq '.workflows[].name'
gh api repos/:owner/:repo/actions/runs --paginate \
| jq -r '.workflow_runs[].id' \
| while read run; do gh api repos/:owner/:repo/actions/runs/$run/logs --header "Accept: application/zip" -o logs.zip; unzip -p logs.zip | grep -E "curl.+bash|bash -c|Invoke-WebRequest|powershell -EncodedCommand|base64 -d|/dev/tcp/"; done
Hunt for suspicious package diffs
# Compare currently installed tree to last good build
npm ls --all --json > now.json
git show LAST_GOOD_BUILD:now.json > then.json # if you persist artifacts
node -e 'const a=require("./now.json"),b=require("./then.json"); /* compare and print surprises */'
Secrets scanning on the repo history
# Quick start with gitleaks (local)
gitleaks detect -v --source .
Staged unfreeze policy
- Keep
ignore-scripts
on until packages in your app are attested and/or maintainers re-verified. - Unfreeze core packages first (React, Express, framework/runtime), then transitive deps.
- Keep mirror registry until change volume returns to baseline for 14 days.
5) Reference configs you can copy-paste
.npmrc
(project)
engine-strict=true
fund=false
audit=true
audit-level=high
ignore-scripts=true
registry=https://registry.npmjs.org
save-prefix=
Policy: block postinstall
locally too
# Developers opt-in local safety
npm config set ignore-scripts true --location=user
Minimal prepublish check
// package.json
{
"scripts": {
"safe:check": "node .ci/verify-lock-integrity.js && npm run lint && npm test",
"prepublishOnly": "npm run safe:check"
}
}
Check Website Vulnerability — sample PDF report summary
How Cyber Rely & Pentest Testing Corp. can help
- CI/CD Risk Assessment (fast-readiness) — Map your SDLC controls to practical threats like the npm supply chain attack 2025, then ship a prioritized, developer-friendly plan.
Start here: Risk Assessment Services at Pentest Testing Corp. - Remediation Engineering — We harden your npm publishing model (trusted publishing + provenance), redesign token scopes, and implement SBOM gates—then prove it with audit-ready artifacts.
Get help: Remediation Services at Pentest Testing Corp. - Self-serve quick win — Scan your public web apps for common exposures while your engineers work the CI plan: Free Website Vulnerability Scanner.
Try it: https://free.pentesttesting.com/.
Prefer a developer-to-developer working session? Book a “Provenance & CI Guardrails” sprint via Cyber Rely.
Recent reads from our blog (for deeper dives)
- Gate CI with CISA KEV JSON: Ship Safer Builds — turn KEV into a hard gate using your SBOM.
- Best 5 Ways for CSRF Prevention in React.js — hands-on patterns you can reuse.
- Business Logic Vulnerabilities in TypeScript — testable guardrails for ERP-scale apps.
- Unrestricted File Upload in React.js — 7 ways to prevent unsafe uploads.
Explore more at the Cyber Rely Blog.
Final checklist (copy into your incident channel)
- Rotate npm/GitHub tokens; move to OIDC in CI
npm profile enable-2fa auth-and-writes
npm ci --ignore-scripts
in all pipelines- Lock semver drift (
save-prefix=""
) - Trusted publishing +
--provenance
on your packages - SBOM gate + KEV/denylist check
- Allowlist maintainers for crown-jewel deps
- Mirror registry + staged unfreeze
- Workflow log sweep + secrets scanning
🔐 Frequently Asked Questions (FAQs)
Find answers to commonly asked questions about the npm supply chain attack 2025.