Here’s a drop-in Bash script to quickly detect if a repo is affected by the Sept-8-2025 npm compromise.
It scans lockfiles, installed node_modules, and (optionally) uses your package manager to enumerate the dependency tree.
- Exit code:
0 = clean, 2 = suspected/affected, 1 = script error.
- Works for npm, pnpm, and Yarn (mono- or poly-repo).
Save as scripts/check-npm-compromise.sh (or anywhere), chmod +x it, then run from a repo root.
#!/usr/bin/env bash
# check-npm-compromise.sh
#
# Fast detector for the Sept-8-2025 npm supply-chain incident.
# Scans lockfiles and installed node_modules for specific malicious versions.
#
# Usage:
# ./check-npm-compromise.sh [--no-lock] [--no-nm] [--pm-ls] [--json]
#
# Exit codes:
# 0 = clean
# 2 = suspected/affected (matches found)
# 1 = script error
set -euo pipefail
# ----------------------------
# Configuration (denylist)
# ----------------------------
# Exact versions confirmed as malicious during the Sept-8-2025 wave.
# Keep entries in the form "name@version".
BAD_VERSIONS=(
"[email protected]"
"[email protected]"
"[email protected]"
"[email protected]"
"[email protected]"
"[email protected]"
"[email protected]"
"[email protected]"
"[email protected]"
"[email protected]"
"[email protected]"
"[email protected]"
"[email protected]"
"[email protected]"
"[email protected]"
"[email protected]"
"[email protected]"
"[email protected]"
)
# Files we scan quickly for string matches (lockfiles/metadata)
LOCKFILES=(
"package-lock.json"
"npm-shrinkwrap.json"
"pnpm-lock.yaml"
"yarn.lock"
)
# Node modules scan roots (common for monorepos)
NM_DIRS=(
"node_modules"
"packages/*/node_modules"
"apps/*/node_modules"
"services/*/node_modules"
)
# ----------------------------
# Flags
# ----------------------------
SCAN_LOCKFILES=1
SCAN_NODEMODULES=1
RUN_PM_LS=0
OUTPUT_JSON=0
while [[ $# -gt 0 ]]; do
case "$1" in
--no-lock) SCAN_LOCKFILES=0; shift ;;
--no-nm) SCAN_NODEMODULES=0; shift ;;
--pm-ls) RUN_PM_LS=1; shift ;;
--json) OUTPUT_JSON=1; shift ;;
-h|--help)
echo "Usage: $0 [--no-lock] [--no-nm] [--pm-ls] [--json]"
exit 0
;;
*)
echo "Unknown arg: $1" >&2
exit 1
;;
esac
done
# ----------------------------
# Helpers
# ----------------------------
notice() { echo "==> $*"; }
warn() { echo "WARN: $*" >&2; }
err() { echo "ERROR: $*" >&2; }
strip_color() { sed -E 's/\x1B\[[0-9;]*[mK]//g'; }
declare -a HITS
record_hit() {
local where="$1"
local pkg="$2"
if [[ $OUTPUT_JSON -eq 1 ]]; then
HITS+=("{\"location\":\"${where}\",\"package\":\"${pkg}\"}")
else
HITS+=("${where} :: ${pkg}")
fi
}
# ----------------------------
# Scan: Lockfiles (fast)
# ----------------------------
scan_lockfiles() {
local any=0
[[ $SCAN_LOCKFILES -eq 1 ]] || return 0
notice "Scanning lockfiles…"
for lf in "${LOCKFILES[@]}"; do
if [[ -f "$lf" ]]; then
while IFS= read -r needle; do
# naive but effective: look for "name" and "version" together by string
local name="${needle%@*}"
local ver="${needle#*@}"
# try common patterns
if grep -q -E "$name[^[:alnum:]-].*${ver}|${name}@${ver}|\"$name\"[^\n]*\"${ver}\"" "$lf"; then
record_hit "$lf" "$needle"
any=1
fi
done < <(printf "%s\n" "${BAD_VERSIONS[@]}")
fi
done
return $any
}
# ----------------------------
# Scan: node_modules (installed tree)
# ----------------------------
scan_node_modules() {
local any=0
[[ $SCAN_NODEMODULES -eq 1 ]] || return 0
notice "Scanning installed node_modules (if present)…"
for root in "${NM_DIRS[@]}"; do
# glob expansion control: skip if no match
shopt -s nullglob
for nm in $root; do
[[ -d "$nm" ]] || continue
while IFS= read -r needle; do
local name="${needle%@*}"
local ver="${needle#*@}"
# Scoped names become paths like node_modules/@scope/name/package.json
local pkgPath="$nm/$name/package.json"
# For scoped packages, the above path is correct as long as $name contains '@scope/name'
if [[ -f "$pkgPath" ]]; then
# Grep the version field quickly (avoid jq dependency)
if grep -q -E "\"version\"\\s*:\\s*\"${ver}\"" "$pkgPath"; then
record_hit "$pkgPath" "$needle"
any=1
fi
fi
done < <(printf "%s\n" "${BAD_VERSIONS[@]}")
done
shopt -u nullglob
done
return $any
}
# ----------------------------
# Scan: package manager 'ls' (optional)
# ----------------------------
scan_pm_ls() {
local any=0
[[ $RUN_PM_LS -eq 1 ]] || return 0
# Determine available PMs
local have_npm=0 have_pnpm=0 have_yarn=0
command -v npm >/dev/null 2>&1 && have_npm=1
command -v pnpm >/dev/null 2>&1 && have_pnpm=1
command -v yarn >/dev/null 2>&1 && have_yarn=1
if (( have_npm + have_pnpm + have_yarn == 0 )); then
warn "No package manager (npm/pnpm/yarn) found on PATH for --pm-ls mode."
return 0
fi
notice "Enumerating dependency tree via package manager (--pm-ls)… (may be noisy)"
# Build the selector list for npm/pnpm (both accept multiple specs)
local specs=("${BAD_VERSIONS[@]}")
if [[ $have_npm -eq 1 ]]; then
# npm ls will return non-zero on peer issues; ignore code and parse output
local out
out="$(npm ls -a --json "${specs[@]}" 2>/dev/null || true)"
for needle in "${BAD_VERSIONS[@]}"; do
local name="${needle%@*}"
local ver="${needle#*@}"
if grep -q "\"name\":\"${name}\"" <<<"$out" && grep -q "\"version\":\"${ver}\"" <<<"$out"; then
record_hit "npm ls (json)" "$needle"
any=1
fi
done
fi
if [[ $have_pnpm -eq 1 ]]; then
# pnpm ls --json (one spec at a time to keep it simple)
for needle in "${BAD_VERSIONS[@]}"; do
local name="${needle%@*}"
local ver="${needle#*@}"
local out
out="$(pnpm ls --depth -1 --json "$name" 2>/dev/null || true)"
if grep -q "\"name\":\"${name}\"" <<<"$out" && grep -q "\"version\":\"${ver}\"" <<<"$out"; then
record_hit "pnpm ls" "$needle"
any=1
fi
done
fi
if [[ $have_yarn -eq 1 ]]; then
# Yarn classic/berry both support 'yarn list --pattern'
local pattern
pattern="$(printf "%s|" "${BAD_VERSIONS[@]}")"
pattern="${pattern%|}"
local out
out="$(yarn list --pattern "$pattern" 2>/dev/null | strip_color || true)"
# Very loose parse: look for lines like "├─ [email protected]"
while read -r needle; do
local safe_line
safe_line="$(grep -E "[[:space:]-─│└├]*${needle}" <<<"$out" || true)"
if [[ -n "$safe_line" ]]; then
record_hit "yarn list" "$needle"
any=1
fi
done < <(printf "%s\n" "${BAD_VERSIONS[@]}")
fi
return $any
}
# ----------------------------
# Main
# ----------------------------
notice "NPM compromise quick scan starting…"
affected=0
scan_lockfiles || affected=1
scan_node_modules || affected=1
scan_pm_ls || true # optional enrichment; do not change affected unless we find hits
# Aggregate decision
if [[ ${#HITS[@]} -gt 0 ]]; then
if [[ $OUTPUT_JSON -eq 1 ]]; then
printf '{"status":"affected","matches":[%s]}\n' "$(IFS=,; echo "${HITS[*]}")"
else
echo
echo "❌ AFFECTED: Found the following suspicious matches:"
printf ' - %s\n' "${HITS[@]}"
echo
echo "Next steps:"
echo " 1) Freeze deployments for this repo."
echo " 2) Regenerate lockfiles with safe overrides/resolutions."
echo " 3) Reinstall with --ignore-scripts, then verify."
fi
exit 2
else
if [[ $OUTPUT_JSON -eq 1 ]]; then
echo '{"status":"clean","matches":[]}'
else
echo "✅ CLEAN: No known-bad versions found in lockfiles or installed modules."
fi
exit 0
fi
# 1) Make executable
chmod +x scripts/check-npm-compromise.sh
# 2) Run from repo root (monorepo supported)
scripts/check-npm-compromise.sh
# Optional: JSON output for CI parsing
scripts/check-npm-compromise.sh --json > scan-result.json
# Optional: also ask your package manager to enumerate the tree (slower/noisier)
scripts/check-npm-compromise.sh --pm-ls
Add a quick job that fails on exit 2:
# .github/workflows/supplychain-check.yml
name: Supply Chain Quick Scan
on:
pull_request:
push:
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run compromise scanner
run: |
chmod +x scripts/check-npm-compromise.sh
./scripts/check-npm-compromise.sh --json | tee scan.json
status=$(jq -r '.status' scan.json || echo "unknown")
if [ "$status" = "affected" ]; then
echo "Found malicious versions."
exit 2
fi