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
.npmrcfile, 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 have access to environment variables and can steal your authentication token. You might think this sounds too paranoid; however, such incidents have already happened: 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 prevents npm from executing any scripts defined in 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.js 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 (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 v7 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 is not stored, it 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 understand 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.