Best Practices

How to Detect Malicious npm Packages: A Workflow

A practical detection workflow for malicious npm packages: install-time signals, registry heuristics, reachability checks, and CI gates that actually block attacks.

Shadab Khan
Security Engineer
7 min read

npm remains the most actively attacked package ecosystem on the planet, and the attack cadence has only accelerated. Typosquats, maintainer account takeovers, post-install script payloads, and dependency confusion attacks show up in the registry weekly. If your detection strategy is "we run npm audit in CI," you're detecting last year's vulnerabilities, not this week's attacks. Detecting malicious packages requires a different posture entirely — one that treats every install as potentially hostile and validates behavior before code touches a developer machine or a production runner.

This guide is the workflow I deploy when a customer asks me to harden their JavaScript supply chain. It assumes you're shipping real software, on deadlines, with developers who don't want to wait five minutes for dependency installs.

What makes npm a high-risk package ecosystem?

The answer is structural. npm's registry permits anonymous uploads, lifecycle scripts (preinstall, install, postinstall) run arbitrary code during package installation, and the average Node.js application pulls in 800 to 1,500 transitive dependencies. Any one of those transitive packages can add a postinstall script on the next minor version bump, and your npm install will execute it — on laptops, on CI runners, and sometimes on production servers if you're running npm install at deploy time.

The attack surface is amplified by how JavaScript developers work. Packages are added with a single command, tree shaking means "unused" code still exists in node_modules, and the cultural expectation of frequent small updates means dependency churn is constant. Attackers exploit this by publishing legitimate-looking packages, building up download counts, and then pushing a malicious version weeks or months later. Your security baseline from last Tuesday isn't valid today.

What are the highest-signal indicators of a malicious package?

A short list of install-time signals catches most of the commodity attacks. Post-install scripts that spawn a subprocess, make network requests to unexpected domains, or modify files outside the package directory are nearly always malicious. Packages with base64-encoded strings longer than a few hundred characters, packages that decode and execute strings at runtime via Function() or eval, and packages with obfuscated identifiers are all high-signal indicators.

Registry metadata gives you a second set of signals. Packages created in the last 30 days with no GitHub repository, packages whose repository field points to a different project than the name field suggests, packages with fewer than 10 historical versions but claiming to replace established libraries, and packages whose maintainer email domain doesn't match any other package they maintain — these are all patterns worth flagging.

Behavioral signals beat static signals when they're available. A package that tries to read ~/.aws/credentials, ~/.ssh/, or the DISCORD_TOKEN environment variable is not doing anything legitimate, ever. Running installs in an instrumented sandbox and capturing syscalls, network connections, and file access produces a much higher-confidence verdict than static scanning alone.

How do I build a pre-install detection gate?

The cheapest gate is the one that runs before npm install does. A pre-install hook can resolve the full dependency tree from package-lock.json, compare it against an allowlist, and veto the install if any package fails policy. Here's the shape of the gate:

#!/usr/bin/env bash
# scripts/verify-deps.sh
set -euo pipefail

LOCK="package-lock.json"
REPORT=$(mktemp)

# 1. Resolve every package and version from the lockfile
jq -r '.packages | to_entries[] | select(.key != "") |
  "\(.value.name // (.key | sub("node_modules/"; "")))@\(.value.version)"' \
  "$LOCK" | sort -u > "$REPORT"

# 2. Check each against the allowlist + malicious-package advisory feed
while read -r pkg; do
  if grep -Fxq "$pkg" policy/denylist.txt; then
    echo "BLOCKED: $pkg is on the malicious-package denylist" >&2
    exit 2
  fi
  if ! grep -Fxq "$pkg" policy/allowlist.txt; then
    echo "REVIEW: $pkg is not on the allowlist" >&2
    REVIEW_NEEDED=1
  fi
done < "$REPORT"

# 3. Disable lifecycle scripts for untrusted packages
if [ "${REVIEW_NEEDED:-0}" = "1" ]; then
  echo "Running install with --ignore-scripts"
  npm ci --ignore-scripts
else
  npm ci
fi

The pattern is: resolve, check, then install. Most teams install first and check later, which means the malicious code has already run by the time you know it existed.

Pair this with npm ci (never npm install in CI — npm install can mutate the lockfile and bypass your gates), enable --ignore-scripts by default, and allowlist the specific packages whose scripts you trust (node-gyp, husky, and a handful of tooling packages generally need them).

How should I handle transitive dependency analysis?

Transitive dependencies are where most of the real risk lives, but they're also where naive detection breaks down because you have too many packages to review. The fix is reachability analysis. For each transitive package, determine whether any of its code is actually callable from your application's entry points. A package that's in node_modules but never imported is not a runtime risk, though it's still a build-time risk if its install scripts run.

Reachability shrinks the review surface by 60 to 90 percent in most codebases. Instead of reviewing 1,400 packages, you're reviewing the 200 that actually contribute code to your shipped bundle. That's a tractable problem.

Don't discard the unreachable packages entirely, though. Lifecycle scripts run regardless of reachability, so your install-time gate still needs to cover every package in the tree. Think of it as two separate analyses: install-time safety (runs on everything) and runtime safety (runs on reachable code only).

What behavioral checks should I run in CI?

In your CI pipeline, run installs in a container with no network access to anywhere except the registry, capture every outbound DNS query attempted by install scripts, and fail the build if any unexpected domain appears. This single check catches the bulk of exfiltration-style attacks because the malicious code needs to phone home to be useful.

Snapshot the post-install filesystem and diff it against a clean baseline. Any file created or modified outside of node_modules is suspicious. Any write to shell profiles, credential stores, or the user's home directory is a hard fail. Many of the recent high-profile npm attacks (the ua-parser-js compromise, multiple coa / rc incidents, the event-stream affair) would have been caught by a simple post-install filesystem diff.

For packages that pass the install-time check, run a short bundle-time analysis on the shipped output. If a package that claims to be a date formatter somehow ends up with code that reads environment variables matching credential patterns, flag it for review. Bundle analysis catches the attacks that delay their payload until runtime.

How do I keep the workflow fast and developer-friendly?

The first rule is to cache aggressively. Allowlist check results, behavioral scan results, and reachability graphs can all be cached by package-version hash. A rerun of the same lockfile should complete in seconds, not minutes. Only new or changed packages need fresh analysis.

The second rule is to make the feedback loop run on pull requests, not on main. Developers will route around any tool that slows their PR by more than a minute or two, so the check needs to be fast enough to run before merge and produce actionable output. "Package left-pad@99.9.9 was flagged because of post-install network activity, review this report" is useful. "Build failed" is not.

The third rule is to integrate the workflow with your existing alerting. New advisory for a package you depend on should page the on-call security engineer and open a tracking issue automatically. Attacks move faster than quarterly security reviews, and your workflow needs to match that cadence.

How Safeguard.sh Helps

Safeguard.sh deploys Griffin AI across every package.json and package-lock.json in your tree, combining reachability analysis at 100-level depth with install-time behavioral sandboxing to separate "this package is in your tree" from "this package can harm you." Eagle tracks the npm registry and threat-intel feeds continuously, pushing new malicious-package advisories into your denylist within minutes of public disclosure — not the next business day. Every build produces a signed SBOM and TPRM record so you can prove which packages were present, which were reachable, and which were sandboxed during install. Container self-healing redeploys affected services automatically when a malicious package is retracted and a known-good version becomes available, closing the window between disclosure and remediation. The workflow stays fast because reachability trims the review surface, and it stays accurate because behavioral detection catches attacks that signature-based tools miss.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.