article

Hardening GitHub Actions in Private Repositories

digital lock in cyberspace

Introduction

GitHub has recently made changes to GitHub Actions to improve security, and published a series of blog posts on how to secure Actions and Workflows (part 1, part 2, part 3). A recurring topic is the securing of GitHub Actions triggered by Pull Requests of untrusted authors. This is typically not a concern in a private repository, where only known and trusted collaborators are allowed to submit PRs. However, there are other security concerns that apply to both public and private repositories, and even unique security concerns that only apply to private repositories: to keep the codebase undisclosed.

In the April 2021 Codecov Bash Uploader incident, attackers compromised the uploader to exfiltrate credentials and code from repositories that used the Codecov Github Action or similar CI integrations. The attack remained unnoticed and unfixed for 2 months.

Besides third-party Github Actions, other attack vectors for GitHub Workflows also include dependency updates in the codebase. When using tools like Dependabot, updates for hijacked packages can result in automatically created PRs that execute malicious code during installation or testing in GitHub Workflows.

An example for this is the 2018 eslint-scope incident, where an attacker gained access to the npm credentials and released package updates with malicious postinstall scripts. In this case, the update was withdrawn in less than 3 hours.

In response to these growing risks, GitHub has recently made several security-related changes to GitHub Actions. Since March, Workflows initiated by Dependabot run with read-only permissions and no access to secrets. One month later, GitHub introduced a more fine-grained permission system for the GITHUB_TOKEN.

In this article, we will discuss how to use this new functionality and other measures to reduce the attack surface of your private GitHub repositories with a focus on Node.js projects.

How to harden GitHub Workflows

Reduce permissions

It is now possible to restrict access to the repository for a GitHub Workflow by changing the permissions for the GITHUB_TOKEN. Keep in mind this does not affect permissions for custom Personal Access Tokens (PAT).

Permission can be set at different levels — organization- or repository-wide by setting the global default permissions, for an entire Workflow or for an individual job inside a Workflow by specifying permissions inside a GitHub Workflow. Local permissions override the global defaults — full details on how the effective permissions are calculated can be found in the GitHub documentation.

By setting the global Workflow permissions to "Read repository contents permission", Workflows will only have read access to the codebase — all other permissions need to be set in the Workflows.

There are currently 12 permission scopes available inside the Workflows. Unfortunately, there is no documentation we know of explaining the scopes, so it may take some trial and error to get the right combination for each Workflow. For one of our favorite "publish" jobs (run semantic-release to determine the next version number and publish a release to the repository), we need write access to the repo content (tag/release), pull-requests (to update the PR comment after publishing) as well as deployments. The permissions look like this:

jobs:
release:
steps:
- name: Semantic release
run: npx semantic-release
permissions:
contents: write
deployments: write
issues: read
pull-requests: write

Audit dependencies before installing

The GitHub Advisory Database collects information about known security vulnerabilities of packages for various ecosystems, including pip and Composer. Since early October, the npm advisory database has also been merged into GitHub's database.

npm's audit command can be used to scan project dependencies against the database. This can be done as part of a GitHub Workflow before installing the dependencies to abort the Workflow execution if vulnerable dependencies are found.

The audit output can be quite noisy at times, and not all vulnerabilities can be exploited in each project setup. To reduce the number of false positives that come up in the audit, it is possible to filter out vulnerabilities with low severity.

jobs:
test:
steps:
- name: Audit Dependencies
# This step will fail if at least one vulnerability with high severity
# is found
run: npm audit --audit-level=high

Avoid executing code during dependency installation

By default, packages installed with npm can execute arbitrary code by defining install or postinstall scripts in their package.json.

These scripts can be skipped by using the switch --ignore-scripts. However, using this switch will cause issues with packages that rely on these scripts to perform legitimate installation tasks such as downloading or compiling platform-specific binaries. In this case, you can manually run the scripts in the Workflow for the packages that use them:

jobs:
test:
steps:
- name: Install Dependencies
run: npm ci --ignore-scripts
- name: Install Sharp
working-directory: node_modules/sharp
run: npm run install

The following one-liner can come in handy to find packages with (post)install scripts:

find ./node_modules/ -name 'package.json' | xargs grep -lE '"(post)?install":'

Limit exposure of secrets

Just like the the token permissions, env variables with sensitive information should not be exposed globally in the Workflow, but only for the steps that actually need them.

jobs:
test:
env:
# non-sensitive values are fine here
NODE_ENV: test
steps:
- name: Run Tests
run: npm test
env:
# the API key is only needed for this step
API_KEY: ${{ secrets.API_KEY }}

Going beyond

Once you hardened your GitHub Workflows as described above, you might ask yourself what else you can do to secure your codebase.

Here are two ideas:

  • also consider dev environments: running npm i on a PR with compromised dependencies locally can expose the development machine — with potentially even worse implications than inside a GitHub Action
  • delay dependency updates: delaying the testing of updates by a few days after their release, you increase the chance of compromised updates being reported in the Advisory Database and picked up by npm audit

If you have any questions or comments, please reach out on Twitter or start a discussion on GitHub.

Author

portrait of Jan Hagelauer

Jan Hagelauer

Senior Software Engineer at Zentered