When doing a CI build, npm ci
or npm cit
is often the preferred way to install dependencies. However, it has some security implications.
Consider the following example:
- In your
.npmrc
file you have a similar line://npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
- You invoke
NODE_AUTH_TOKEN=your-secret-token-here npm ci
What happens here: if you want to install private packages, you need to pass a secret token to npm
so that it can authenticate against the registry. The token is passed as an environment variable. Then, when npm ci
installs a package, it, among other things, executes various scripts bundled with that package. Those scripts do have access to the environment variables, and they do have an opportunity to steal your authentication token. You might think this sounds too paranoid: however, such incidents have already happened in the past: eslint-scope and eslint-config-eslint, crossenv, 1337qq-js, to name a few.
In general, the attack surface is not limited to environment variables: when you install packages on your computer, lifecycle scripts have access to your filesystem, and they can try to read your SSH keys, etc. This is less of an issue when you run your CI builds in a restricted environment; however, if you expose your secrets in environment variables, they are at risk.
Let us look at some real world examples.
In this example the author exposes their GITHUB_PUBLISH_TOKEN
, which can be used to publish packages to the GitHub package registry on behalf of the author:
- run: npm ci env: NODE_AUTH_TOKEN: ${{secrets.PUBLISH_GITHUB_TOKEN}}
If a malicious script steals this token, it can compromise any package published by the author.
Here is another example:
- name: npm publish run: | npm config set //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN npm ci npm publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}
If NPM_AUTH_TOKEN
gets stolen, this could affect all author’s packages in the npm registry.
In this example the author exposes NODE_AUTH_TOKEN
way too often. This violates the least privilege principle, dictating that a module should possess only the information and resources necessary for its legitimate purpose.
- run: npm ci env: NODE_AUTH_TOKEN: ${{ secrets.GPR_SECRET }} - run: npm run build env: NODE_AUTH_TOKEN: ${{ secrets.GPR_SECRET }}
Moreover, even the official GitHub documentation suggests this insecure way to install dependencies from the private registries 🙁
OK, so how do we protect against this kind of attack? npm install
accepts an option --ignore-scripts
, which causes npm to not execute any scripts defined in the package.json
. Although this is not documented on the manual page, npm ci
also accepts this option. Wait, but what if you do need to run a post-install script — for example, if the package needs to build a native Node addon?
For that, there is a poorly documented npm rebuild
command. Not only does it build native addons, but it also runs preinstall, install, and postinstall scripts.
To completely mimic the behavior of the normal npm ci
/ npm install
, we need to execute npm run install
and npm run prepare
(normally npm runs prepare
scripts on local npm install
without any arguments).
The “clean slate install and test” workflow will now look like this:
# Set the default value of NODE_AUTH_TOKEN # so that npm does not complain about # missing environment variables NODE_AUTH_TOKEN= # Run npm ci safely NODE_AUTH_TOKEN=your-secret-token-here npm ci --ignore-scripts # Run pre/post-install scripts # (note that NODE_AUTH_TOKEN will be empty) npm rebuild # Run this package's install scripts if any # Can be omitted if the package has no pre/post/install scripts npm run install --if-present # Run prepare script if any npm run prepare --if-present
If you have any other sensitive environment variables you would rather not expose, you can run npm rebuild
in a subshell:
( unset MY_SECRET_VAR npm rebuild )
unset
will affect only the current shell, but the parent shell will retain its copy of MY_SECRET_VAR
.
npm 7
npm 7 has changed the way npm ci
works.
Consider the following package.json
file:
{ "name": "npm-test", "version": "1.0.0", "description": "", "scripts": { "prepare": "echo Prepare", "preinstall": "echo Preinstall", "install": "echo Install", "postinstall": "echo Postinstall", "prepublish": "echo Prepublish", "test": "echo Test" } }
Running npm ci
with npm@6 yields this result:
$ npm cit > [email protected] preinstall /npm-test > echo Preinstall Preinstall > [email protected] install /npm-test > echo Install Install > [email protected] postinstall /npm-test > echo Postinstall Postinstall > [email protected] prepublish /npm-test > echo Prepublish Prepublish > [email protected] prepare /npm-test > echo Prepare Prepare added 0 packages in 0.037s
However, with npm@7 the output will be different (and there is a bug report for that):
$ npm ci up to date in 164ms found 0 vulnerabilities
Thus, npm@7 currently (I tested this with various npm versions up to 7.1.0) behaves as if --ignore-scripts
were passed to npm ci
.
Conclusion
To summarize:
- never hardcode credentials in the files: if sensitive data are not stored, they cannot be stolen. Therefore it is wise to avoid storing npm authentication tokens in
.npmrc
; - know the tools you are using: become familiar with npm internals to know what exactly it does, and which external scripts it can run;
- if possible, run untrusted code in a restricted environment;
- remember, security is a process and not a state.