Safeguard
Supply Chain Security

Detecting malicious postinstall scripts in npm packages

A 2025 phishing attack compromised 18 npm packages with 2.6 billion weekly downloads. Here's how postinstall scripts became npm's top attack vector.

Safeguard Research Team
Research
6 min read

Every npm install you run can execute arbitrary code before you've opened a single file, because package.json supports preinstall, install, and postinstall lifecycle hooks that npm runs automatically for every direct and transitive dependency in the tree. GitHub has described install-time lifecycle scripts as the single largest code-execution surface in the npm ecosystem, and the numbers back that up: on September 8, 2025, a phishing email against maintainer Josh Junon compromised roughly 18 packages — including chalk, debug, ansi-styles, and strip-ansi — with a combined 2.6 billion weekly downloads, all through the same install-hook mechanism. Days later, the self-propagating Shai-Hulud worm used npm postinstall scripts to spread across a reported 500+ packages, a scale that pushed npm supply-chain attacks from nuisance-level typosquats into worm-grade incidents. In June 2026, GitHub announced plans to disable npm install scripts by default — a direct response to years of incidents like these. This post walks through how postinstall attacks actually work, the incidents that prove the pattern, and the detection and sandboxing techniques that let teams keep dependency installs functional without running blind.

Why do postinstall scripts exist, and why are they dangerous by default?

Postinstall scripts exist for legitimate reasons: packages with native bindings, like node-sass or bcrypt, use them to compile C++ code via node-gyp after install, and some CLI tools use them to download platform-specific binaries. The danger is that npm makes no distinction between that legitimate compilation step and arbitrary attacker-supplied JavaScript or shell commands — both run with the same permissions as the user invoking npm install, on developer laptops and CI runners alike, with full filesystem and network access. npm's --ignore-scripts flag disables all lifecycle hooks, but it's underused for two reasons: it doesn't stop other execution paths like Git hooks, and it breaks any package with a real native-build step, forcing teams into manual allowlisting just to get a working install. That friction is exactly why GitHub's move to disable install scripts by default is significant — it flips the safe default for an ecosystem that has defaulted to "run everything" since npm's creation.

What did the event-stream and ua-parser-js incidents teach the ecosystem?

The event-stream incident in 2018 was one of the earliest widely documented cases: the original maintainer transferred publishing control to an unfamiliar contributor, who quietly added a dependency carrying code that targeted users of the Copay bitcoin wallet. It demonstrated that maintainer-transfer social engineering, not just credential theft, could poison a dependency with tens of millions of downloads. Three years later, in October 2021, ua-parser-js — used by countless projects to parse browser user-agent strings — had three compromised versions (0.7.29, 0.8.0, and 1.0.0) published within hours of legitimate releases, each shipping a cryptominer and credential-stealer payload triggered through the install process. Both incidents established the template attackers still use today: compromise a trusted maintainer's account or identity, ship a malicious version that looks like routine maintenance, and let the install-time execution do the rest before anyone reviews the code.

How did the 2025 chalk/debug compromise and Shai-Hulud worm raise the stakes?

The September 2025 compromise of chalk, debug, ansi-styles, strip-ansi, and related packages started with a phishing email that tricked maintainer Josh Junon into handing over publishing credentials, then affected roughly 18 packages representing 2.6 billion combined weekly downloads — an order of magnitude beyond ua-parser-js in blast radius. Weeks later, the Shai-Hulud worm went further structurally: rather than a single malicious payload, it used compromised npm credentials to republish itself into other packages it could reach, self-propagating across a reported 500-plus packages, according to coverage referencing Unit 42 and CERT/CC's VU#534320 advisory. Shai-Hulud mattered because it showed install scripts being used not just to steal credentials once, but to automate further compromise — turning a single phished maintainer into a worm that spread through the dependency graph on its own.

Is this pattern slowing down, or getting worse?

It's continuing. In March 2026, compromised maintainer credentials led to two malicious versions of Axios — 1.14.1 and 0.30.4 — being published, showing that even security-conscious, heavily-audited packages remain exposed as long as a single maintainer's npm token can be phished or stolen. Axios sits deep in the dependency tree of a large share of the JavaScript ecosystem, so a malicious version reaching even a small percentage of installs before detection represents meaningful exposure. The consistent thread across event-stream, ua-parser-js, the 2025 chalk/debug compromise, Shai-Hulud, and Axios is that none of them required a novel exploit — they all relied on the same install-time execution path that npm has run by default since its creation, triggered through ordinary account compromise rather than a technical vulnerability in npm itself.

What actually works for detecting malicious install scripts before they run?

Static analysis of the package tarball before execution is the foundation: scanning package.json lifecycle hooks and bundled JavaScript for obfuscation patterns — dense eval, Function constructors, base64 or zlib-wrapped payloads — without ever running the code. Behavioral diffing between package versions catches a narrower but high-signal case: a new release that suddenly makes network calls, writes outside its own directory, or spawns child processes that the prior version never did is a strong indicator of compromise, independent of whether the payload is obfuscated. npm audit signatures verifies package provenance against registry signing, lockfiles and version pinning reduce the window in which a compromised version can silently reach a build, and routing installs through a registry-fronting proxy lets you evaluate a package before it ever touches disk rather than after. None of these techniques require executing untrusted code to get a verdict, which is the property that matters most: a detector that has to run the payload to classify it has already lost.

How does sandboxing complement static detection?

Sandboxing addresses what static analysis can't fully rule out — scripts that use runtime obfuscation, delayed execution, or environment-fingerprinting to only trigger their payload under specific conditions. Running installs in ephemeral, network-restricted containers or VMs means that even a script that evades static detection and executes anyway is contained: it can't reach ~/.aws/credentials or ~/.ssh outside the sandbox, and it can't exfiltrate over the network if egress is denied by default. The practical middle ground many teams land on is allowlisting known-safe native-build packages (so node-gyp-based installs keep working) while blocking --ignore-scripts-incompatible installs for everything else, combined with quarantining first-seen packages until a behavioral or static verdict clears. This is where Safeguard's own approach fits the pattern directly: our Package Firewall runs as an install-time proxy in front of npm and pip that performs static behavioral analysis on every package — including transitive dependencies — without executing any code, holding flagged packages in quarantine (aged out after 14 days by default) rather than either blindly installing or hard-blocking. Behind that check, our Eagle classification model scores install-script behavior specifically — unusual hosts contacted, extra packages installed, writes outside the package's own directory — as one of seven indicator classes, enforced inline at install, at the registry, in CI, and at Kubernetes admission.

Never miss an update

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