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:

  1. In your .npmrc file you have a similar line:
    //npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
    
  2. 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.
A Secure Way to Run npm ci
Tagged on:             

Leave a Reply

Your email address will not be published. Required fields are marked *