GitHub Actions to securely publish npm packages
2020年11月10日
0 分で読めますGitHub Actions are growing in popularity ever since GitHub announced general availability for all developers and repositories on the GitHub platform. Fueled with some rate limits we’re seeing in the ecosystem—such as new billing and rate limits for open source from Travis CI—will further drive developers to migrate their software automations to GitHub Actions.
In this article, I’d like to show you how I’m using GitHub Actions to publish npm packages that I maintain in my open source projects. If you’re following the GitHub flow which consists of GitHub pull requests workflows, then this will further unify your experience around GitHub workflows for your teams and community contributors in your project.
What is GitHub Actions?
GitHub Actions is a technology developed by GitHub to provide developers with a way to automate their workflows around continuous integration—helping them build, deploy, schedule recurring tasks, and more. GitHub Actions is natively available and integrated into GitHub repositories and features many reusable workflows from community contributors, such as publishing npm packages, publishing docker images, running security tests, and more.
How do actions work in GitHub?
GitHub’s cloud infrastructure is running GitHub Actions for users by creating a workflow file in a GitHub repository directory named .github/workflows
, describing the trigger, schedule for the job, and the actions the job should take using YAML.
What is a GitHub workflow?
A GitHub workflow is a set of jobs that would run based on a trigger or a cron-based schedule. A job consists of one or more steps that make up an automated workflow.
Setting up a Node.js project for GitHub Actions
Let’s add an initial GitHub Actions automation to a Node.js project. The GitHub Actions job will install all required npm packages, run tests, and eventually publish our project as an npm package that users can consume.
Our npm package is going to be a Command Line Interface (CLI) for you to browse the amazing list of talks from SnykCon 2020—Snyk’s first-ever global security event that took place in 2020.
The complete project is hosted on GitHub. Here's a sneak peek at how the npm package looks:
A complete GitHub CI workflow starts off with creating the following GitHub Action file at the root of the repository path: .github/workflows/main.yml
name: main
on: [push]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [12.x, 14.x]
steps:
- uses: actions/checkout@v2
- name: Build on Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- run: npm ci --ignore-scripts
- run: npm run build --if-present
name: Build
- run: npm test
env:
CI: true
The above is very much a stock template for a build and test process of Node.js projects, doing the following:
The trigger is on every push, to every branch.
We run this action on Node.js versions 12.x and 14.x to ensure compatibility across both Node.js LTS versions.
The job then runs several steps that check out the git codebase, runs a secure and deterministic npm install phase, continues to run a build step if present, and finally runs tests.
As you push new commits to the repository, whether you are following a GitHub flow, or a GitHub pull request workflow, you’ll see new jobs queuing up in the repository’s Actions tab. Like the following, that shows our tests running for npm package snykcon:
Publishing npm packages with GitHub Actions
If all tests pass, and this automation workflow runs in our main branch, we could automate releasing new versions altogether!
When it comes to releasing packages, it’s a good idea to consider the security implications that concern such actions. Let’s review a few of them:
Malicious code: If a vulnerability is intentionally added by someone as part of the code contribution and you missed it, an automatic release when merging to your main branch means it will be available to users immediately. If you manually release immediately, there’s no difference either—however, the more time passes between merging pull requests and releases, it gives more time for the community to scrutinize and help flesh out such issues.
Vulnerable dependency: If a malicious person adds a dependency with known public vulnerability then this dependency will trickle into your users as it gets pulled during the install process. Using a free tool like Snyk to connect to your git repositories and add a status check to prevent you from releasing with vulnerable versions of dependencies, solves exactly this problem.
Malicious dependency through a lockfile: Have you considered what would happen if a contribution to update package.json dependencies would actually inject a malicious package into the lockfile, which nobody takes the time to review anyway, right? I wrote about why npm lockfiles can be a security blindspot for injecting malicious modules so make sure you deep dive into this if you hadn’t considered this attack vector.
Stealing your npm token:In the past, quite a few attempts of malicious packages circled around the notion of stealing a person’s npm token or other sensitive information that exists in environment variables.
The above isn’t meant to be a comprehensive list of security concerns, but it definitely highlights quite a few that we should be aware of.
Speaking of security highlights, do you find that list of possible security concerns an interesting and educational read? It’s a quick threat modeling process, which you can practice more regularly when you build out features. We wrote about it in our DevSecOps Process and there’s a good talk from SnykCon by Alyssa Miller on User Story Threat Modeling: It’s the DevSecOps Way. Check them out!
Let’s go back to our list and focus on the last security concern mentioned—stealing your npm token. Since this article is all about publishing npm packages, it means we need to make an npm token available to the GitHub Actions workflow and this has historically been frowned upon for the following reasons:
npm capabilities: historically, releasing npm packages using an npm token, required your npm user to disable two-factor authentication. It’s not anymore and we’re going to learn how!
Stealing a token from malicious packages install:if you make the npm token available in your CI as environment variables, then malicious packages that exist in your package dependency tree (beyond your own direct dependencies) may have access to it during, for example, the npm install process, which by default allows packages to run any arbitrary command.
So how do we handle these two valid security concerns?
Firstly, npm has recently made two-factor authentication possible along with issuing automation tokens, so these two capabilities no longer conflict. Secondly, GitHub Actions allows you to make environment variables information available only to a specific step in a job, which means we can make it available only to the npm publish command and not npm install which would’ve allowed indirect dependencies access to it as well.
Let’s get started.
First, enable two-factor authentication for your npm user. Navigate to your account settings on https://npmjs.com/ and enable 2FA Mode for both authorization and publishing.
You’ll need to associate an authenticator device, for example, the Google authenticator app on your mobile device, or 1Password if you’re using that to manage your passwords.
Then, head over to Access Tokens management on npm, and create a new token.
Make sure you create an Automation type token, as shown in the screenshot below which, as the description says, will bypass two-factor authentication and allow you to use it from continuous integration (CI) workflows:
We can then make this token available in our GitHub Actions by first creating it as a secret in the GitHub repository secrets management, like this:
And finally, updating our GitHub Actions workflow to also include a release step. After the build job, add the following publish job:
publish:
if: github.ref == 'refs/heads/main'
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 14
- name: Publish
run: |
npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
npm publish --ignore-scripts
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
It is vital to note the --ignore-scripts
argument to the npm publish
command, which is critical to a safe publishing workflow. It tells the publish command from the npm CLI to skip all the life cycle scripts specified in the packge.json
manifest. This is important—when the malicious package gets installed as part of the dependency graph, it can create an entry such as prepack: “echo ‘do something malicious’”
to your own package, which will be triggered when you run npm publish
. As you can imagine, that malicious entry can do more harm than just print something to the screen.
The workflow we’re following here significantly reduces such a concern of npm tokens being stolen, or other targets of arbitrary commands execution because:
In our build job, we install packages without allowing them to execute arbitrary commands thanks to the
--ignore-scripts
command argument provided tonpm ci
.Our publish job starts in a fresh new repository checkout. This means that even if it was required to allow script execution as part of npm package installation of the prior build step, all malicious attempts to inject data to the package’s
package.json
file would be futile.As a precaution, our publish step includes a
--ignore-scripts
command argument to avoid executing npm life cycle scripts during this phase.The npm automation token to release a package is only made available to the publish step.
Given all the above, you would probably sleep better at night by requiring two-factor authentication on every version publishing of the package. That, however, requires a more elaborate setup if you need to enable this in a CI environment and we’ll skip that in this article.
This job specifically runs only on the main branch—so we don’t release during a pull request test run—and only runs with a specific Node.js version, rather than parallelizing job runs of different versions.
Once a GitHub pull request is merged, or a commit is pushed to the main branch, the following publish job will execute and trigger a release for the npm package.
View the complete GitHub Actions workflow file for a reference.
It is important to note that we didn’t cover any sort of automated semantic versioning with regards to bumping the versions of our npm packages automatically, based on whether a commit push was a patch, minor or major update to the code. For that, I recommend evaluating semantic-release which the Snyk Advisor also suggests is a very healthy package:
Where do GitHub Actions run?
The scope of GitHub Actions is for a specific repository, and they are executed and managed using GitHub’s cloud infrastructure services, and provide support for Mac, Windows, and Linux platform runners. It is, however, possible to also have self-hosted GitHub runners such as on Google Cloud Infrastructure.
What is GitHub flow?
The scope of GitHub Actions is for a specific repository, and they are executed and managed using GitHub’s cloud infrastructure services, and provide support for Mac, Windows, and Linux platform runners. It is, however, possible to also have self-hosted GitHub runners such as on Google Cloud Infrastructure.
To sum it up
To summarize, building npm packages and releasing them to the broader npm ecosystem can be automated in a secure way using GitHub Actions. The benefits of choosing GitHub Actions is that we maintain the entire developer experience of a git workflow within the GitHub platform.
I also recommend following up on more GitHub Actions integrations that you can easily plug into your project:
Connect Snyk to your Git repository to get automated fix pull requests.
You can use Snyk GitHub Actions to test your npm package dependencies, and even your docker container images. Official repository: https://github.com/snyk/actions.
Try out my is-website-vulnerable GitHub Action if you want to track end-to-end security tests for a website (detecting vulnerable JavaScript libraries).