This article explains the importance of understanding how npm lifecycle scripts such as preinstall and postinstall work, how they could, (and have been) used maliciously, and offers some advice on what you can do about it as part of your npm project health.

Issue

If you are using npm, the popular JavaScript package manager, you have likely heard about the eslint-scope package attack, where control over the heavily relied upon package was obtained by a malicious actor, who published a new version containing malicious code. That malicious code was executed using a postinstall lifecycle script, meaning each user who installed the package potentially had their npm registry login details sent to a remote address by the malicious script. The official npm incident report can be found here, but the important detail is that unknowingly, users running the familiar npm install command had no idea that this malicious code was being executed during the postinstall step. As these scripts run by default, any background tasks they execute are often hidden from the user, who is only looking for feedback on whether or not the new thing they installed is ready.

Example:

// package.json file "name: "express-example-package",

"scripts": {

"postinstall": "node malicious.js"

}

If the above package.json file was an example package on the npm registry, any user who decided to npm install express-example-package would have the above postinstall script executed - the contents of this script file could be anything! The same behaviour is true for the scripts preinstall , postinstall , preuninstall and postuninstall . The npm reference for these scripts are available here.

Prevention

In reality, preventing this issue is much harder than one simple solution. These lifecycle hooks are an important feature - they can help set up packages in complex ways and perform important cleanup or preparation tasks, so it can be limiting to simply opt out of running these hooks. Due to this, the recommended approach to prevent this issue will always be to 1) review dependencies carefully, and use a lockfile to prevent auto-installing new packages

2) Opt out of running scripts on install

When installing a package, you can chose to opt out of running scripts using the ignore-scripts option in npm or Yarn:

npm install --ignore-scripts

yarn add --ignore-scripts

Or, if you never want to run these scripts when installing packages, you can modify your global npm configuration:

npm config set ignore-scripts true

yarn config set ignore-scripts true

Note: this setting could also be added to a project’s .npmrc or .yarnrc file.

3) Audit current modules script use

I found a lack of tooling available to see which dependencies in the current dependency tree are currently running lifecycle scripts, so have published a cli tool to view this information. It is available on npm.

To use the tool, you can install it globally npm install npm-viewscripts -g .

Inside a project directory, running npm-viewscripts will provide a list of any dependencies currently installed which have, and will continue to run a script on install or uninstall.

My thoughts

This type of vulnerability is not a fault of npm, as these lifecycle scripts are a very helpful feature for package management, however the risks need to be understood by users. It could be argued that the issue has a wider scope with npm due to the large size of a typical Node.js application’s dependency tree, and the philosophy of building and sharing and consuming many small and discrete modules.

I think it is important that the community, and in particular maintainers of popular packages take precautions to prevent these attacks from continuing - the best prevention against this is to enable 2FA for all npm accounts with write access to packages. With this enabled, any npm credentials which are stolen by one means or another have less harm on consumers of the package, but that of course does nothing to prevent a trusted maintainer suddenly going rogue.