npm install should not mean: “run foreign code on my computer”

You type in the terminal:

npm install

or:

pnpm install

You expect to download the libraries required by the application. For many years, however, in the Node.js ecosystem this operation meant something more: download other people’s code and automatically run some of that code on my computer.

This distinction matters. Downloading a file and executing a program are two different trust decisions.

As of 2026, npm 12 blocks dependency installation scripts by default until the project explicitly approves them. This is a major and necessary change. However, older npm versions, other tools, and later commands such as build or test may still run dependency code. The problem has not disappeared—it has simply become more visible. The npm documentation describes the current allowScripts mechanism, and GitHub explains the default behavior change in npm 12.

What exactly runs during installation?

A package may contain lifecycle scripts, including:

preinstall
install
postinstall

Legitimate packages use them, for example, to compile a C++ addon, download a system-specific binary file, or prepare assets.

Technically, however, an entry such as:

{
  "scripts": {
    "postinstall": "node setup.js"
  }
}

is not an innocent configuration instruction. It is a command to run the setup.js program.

If the program runs directly on a laptop, it receives the permissions of the logged-in user. It can attempt to read files, launch additional processes, and connect to the internet. Interesting targets include:

~/.npmrc             package publishing tokens
~/.ssh/              SSH keys
~/.aws/              AWS credentials
~/.kube/config       access to Kubernetes clusters
.git-credentials     stored Git credentials

On a developer’s computer, this may mean stolen code and accounts. On a CI server, the damage can be greater because the runner may have keys for the package registry, repositories, cloud services, and production environments.

This is not a vulnerability with a single CVE number. It is a risky trust boundary: foreign code is granted the ability to run with host permissions.

Four incidents that illustrate the escalation of the problem

1. ESLint, 2018: from a package to a publishing token

In July 2018, an attacker took over the account of one of ESLint’s maintainers and published malicious versions:

eslint-scope@3.7.2
eslint-config-eslint@5.0.2

Their postinstall downloaded code from Pastebin, executed it, and sent the attacker the contents of the local .npmrc file. That file often contained a token allowing the publication of additional packages.

The impact therefore had two levels:

  1. theft of a secret from the victim’s computer;
  2. the ability to use that secret to poison subsequent packages.

npm revoked tokens issued before the incident. There is no public evidence that the stolen tokens were used at the time to massively further propagate the attack. However, the mechanism itself was demonstrated very clearly. Official ESLint postmortem.

An unusual detail: according to the postmortem, the maintainer’s account did not have 2FA enabled, and its password had previously also been used on other services. A supply-chain attack can therefore begin not with a bug in code, but with a reused password.

2. ua-parser-js, 2021: XMRig and DanaBot on developers' computers

In October 2021, the account of the author of the popular ua-parser-js was compromised. Malicious versions of 0.7.29, 0.8.0, and 1.0.0 contained a preinstall.js script that detected the operating system and selected the payload.

The attack installed:

  • XMRig — a Monero cryptocurrency miner that consumed the victim's CPU resources;
  • DanaBot on Windows — a banking trojan and credential stealer.

This was not “a malicious JavaScript snippet running in the application.” The installation script downloaded and executed a program at the operating-system level. Mandiant tracked this incident as activity by the UNC3379 cluster. The Mandiant analysis describes both payloads.

GitHub recommended treating a device on which a malicious version was installed as fully compromised: remove the package, but also rotate all keys and secrets from another, clean computer. npm uninstall alone does not remove the program that the package may already have installed. GitHub Advisory: CVE-2021-4229 / GHSA-pjwm-rvh2-c87w.

3. coa and rc, 2021: passwords, screenshots, and a keylogger

A few weeks later, the coa and rc packages were compromised. The malicious preinstall launched a Windows chain consisting of compile.js, a batch file, and a downloaded DLL loaded by the system regsvr32.exe.

The DLL:

  • stole passwords from browsers;
  • searched for credentials in FTP, VNC, and email clients;
  • captured screenshots;
  • logged keystrokes.

This is a feature set characteristic of a credential stealer combined with a keylogger. Mandiant linked the infrastructure of these incidents to DanaBot, while CERT-EU, in its advisory, cautiously describes the DLL's capabilities without conclusively identifying the malware family. CERT-EU SA2021-062 contains package versions, files, and other indicators of compromise.

4. Shai-Hulud, 2025–2026: malware that published its own copies

Shai-Hulud changed the scale of the threat. It was a supply-chain worm: it not only stole tokens, but also attempted to use them to publish infected versions of additional packages owned by the same person.

The first wave in 2025 primarily targeted postinstall. Shai-Hulud searched for, among other things, npm and GitHub tokens as well as credentials for AWS, Azure, and Google Cloud. A stolen publishing token turned one victim into another source of infection. GitHub describes this self-replication and the multi-wave nature of the campaign in its npm security summary.

In Shai-Hulud 2.0 from November 2025, the chain began earlier, with preinstall. The setup_bun.js script checked whether the Bun runtime was available and installed it if necessary. It then launched the actual data-stealing module.

This variant could also:

  • publish stolen data in GitHub repositories;
  • register the device as a self-hosted GitHub Actions runner, creating a channel for later command execution;
  • infect additional packages using npm tokens;
  • under certain conditions, execute destructive commands that delete data.

Datadog identified at least 796 packages and 1,092 versions containing Shai-Hulud 2.0. These figures describe identified artifacts, not the number of computers on which the malware definitely executed. The Datadog Security Labs analysis also provides a list of indicators of compromise.

In May 2026, Microsoft described another variant, Mini Shai-Hulud: more than 170 npm packages, two PyPI packages, and 404 malicious versions in total. An interesting feature was the error-masking mechanism: a malicious optional dependency executed the payload and then deliberately terminated with an error. npm could treat this as a failed installation of an optional add-on and continue, while the code had already executed. The malware also added a persistence mechanism to the Claude Code configuration. Microsoft Security analysis.

What these incidents prove — and what they do not

They prove that a dependency script executed on a host can use everything accessible to the user or CI runner.

They do not prove that every installation script is malicious. Nor do they prove that disabling postinstall solves the problem. Dependency code is also executed by bundlers, code generators, test runners, and commands such as:

npm run build

Therefore, the right question is not: “Does this project have postinstall?” It is: what foreign code are we executing, and what will it have access to?

Git demonstrates a better default: clone != execute

Git also has hooks, meaning programs executed in response to events such as commit, checkout, or push. Traditionally, they reside in the local .git/hooks/.

The key difference is that active hooks and local configuration are not ordinary, versioned content copied by git clone. The Git documentation in the SECURITY section states explicitly that configuration and hooks can execute arbitrary commands, but they are not copied during cloning; therefore, simply cloning and browsing untrusted content is generally safe.

“Generally” does not mean “always.” You should not run commands in a .git directory received directly from an untrusted person, and implementation flaws may break this boundary.

As early as 2006, Linus Torvalds, responding in a discussion titled “Security problem,” distinguished the object database fetched from a repository from the local checkout and index state. This is not evidence that he foresaw modern attacks on package managers. However, it is an early example of consciously separating transported data from local execution state. Git mailing list discussion archive.

CVE-2024-32002: path traversal turned clone into RCE

In 2024, the critical vulnerability CVE-2024-32002 was discovered, classified among others as CWE-22: path traversal.

A specially crafted repository with submodules could deceive Git on a case-insensitive filesystem that supports symlinks. Instead of writing a file to the submodule working directory, Git placed an executable hook inside .git/. The hook ran during:

git clone --recurse-submodules ...

The result was RCE — remote code execution, meaning the execution on the victim's computer of commands chosen by the author of the malicious repository before the user had time to inspect the code.

The official advisory rated the vulnerability at 9.0/10 and lists fixed releases, including Git 2.45.1, 2.44.1, and 2.43.4. GHSA-8h77-4q3w-gfgv / CVE-2024-32002.

This vulnerability was dangerous precisely because it bypassed Git's normal rule: a remote repository should not install local hooks.

CVE-2026-52726 in Dulwich: the same boundary, a different flaw

In 2026, a similar issue was found in Dulwich, a Python implementation of Git formats and protocols.

CVE-2026-52726 is also a path traversal (CWE-22) vulnerability. The submodule_update() and clone(..., recurse_submodules=True) functions improperly validated the submodule path. A malicious .gitmodules could literally target:

.git/hooks

Dulwich wrote the attacker's files there and preserved their executable bits. A later Git or Dulwich operation invoking the relevant hook led to RCE.

Unlike CVE-2024-32002, this attack did not require a case-insensitive file system: the literal .git/hooks path worked on Linux, macOS, and Windows. Versions from 0.23.2 to 1.2.4 were vulnerable; the fix was released in Dulwich 1.2.5. GHSA-gfhv-vqv2-4544 / CVE-2026-52726.

Both CVEs demonstrate the same rule: an attacker often does not need a new execution mechanism. It is enough to write their file to a location that the system already considers authorized to run code.

If code must run, limit its environment

Building an application requires executing code. Risk cannot be reduced to the slogan “disable all scripts.” However, the scope of potential damage can be reduced.

In GoatCMS, the installation and build task can be run in an isolated container, for example:

run:docker --image=node:22 --interactive=false --body=<<WEBEOF
    set -e

    mkdir -p /tmp/bin
    corepack enable --install-directory /tmp/bin
    export PATH="/tmp/bin:$PATH"

    pnpm install --frozen-lockfile
    pnpm build
WEBEOF

The commands remain the same. The process environment changes. A dependency does not need access to the host's home directory, SSH keys, or cloud configuration. It receives only the resources explicitly provided to the task.

This describes GoatCMS's security model, not a claim that every container automatically provides complete isolation.

A container can have backdoors the size of a gateway

Such an execution would still be risky:

docker run \
  --privileged \
  -v /:/host \
  -v /var/run/docker.sock:/var/run/docker.sock \
  ...

--privileged expands the process's capabilities. Mounting / exposes the host file system. The Docker socket allows commands to be issued to the daemon, which can create additional containers and mount host files. In practice, this is often a path to very broad control over the machine. Docker Engine security documentation describes the importance of protecting access to the daemon.

A container limits damage only when its capabilities are actually restricted:

  • mount only the directories that are needed;
  • provide only the necessary, short-lived secrets;
  • do not use --privileged unless necessary;
  • do not mount the Docker socket;
  • restrict outbound traffic if the build does not need internet access;
  • remove the environment after the task completes;
  • pin the image to a specific digest when reproducibility is required.

A lockfile helps, but it is not enough

A lockfile records the exact versions of dependencies. As a result, a new malicious version should not be downloaded merely because it falls within the broad version range in package.json.

However, it is not a security certificate. If a malicious version has already been accepted and recorded in the lockfile, subsequent installations will reproduce it very consistently.

It is worth combining several layers:

  1. pinned dependencies and mandatory review of lockfile changes;
  2. explicit approval of installation scripts;
  3. phishing-resistant MFA and publishing via OIDC instead of long-lived tokens;
  4. isolated, ephemeral build environments;
  5. minimal mounts, permissions, and secrets;
  6. network restrictions and monitoring for exfiltration attempts;
  7. verification of artifact provenance, not just a link to the repository.

An interesting sign of change in the industry: npm 12 blocks dependency scripts unless approved. Previously, even --ignore-scripts did not close every avenue. A dependency installed from Git could contain .npmrc, which replaced the program used as Git and achieved code execution through another path. This is why npm 11 introduced --allow-git, and npm 12 also restricted Git dependencies by default. Description of the --allow-git mechanism.

Do not ask only whether you trust the author

A package author does not need to have malicious intent. A compromised account, stolen token, vulnerable publishing process, or compromised transitive dependency is enough.

The question “is this maintainer trustworthy?” is therefore too narrow. A better one is:

What will this code be able to do if the author's account is compromised tomorrow?

Git expresses a good default in a simple rule:

clone != execute

Build systems need similar boundaries:

download != trust
install != host access
build != unrestricted permissions

Isolation will not make malicious code safe. However, it can ensure that one compromised package does not also take over a developer's laptop, cloud keys, and the entire publishing process.

This is the proper goal of containerized scripts in GoatCMS: not a promise that every dependency is honest, but the assumption that one of them will eventually not be — and preparing the system for that day.

Sources

npm and supply-chain incidents

Git, vulnerabilities, and isolation