Skip to content

GitHub

If you only do a few things, do these:

  • Set up branch protection on the default branch.
  • Use GITHUB_TOKEN or GitHub App tokens, not Personal Access Tokens (PATs) with broad access.
  • Remove privileged workflow triggers (pull_request_target, workflow_run).
  • Pass untrusted values to shell commands through environment variables, not inline expressions.
  • Pin third-party actions to a full commit SHA.
  • Set minimum permissions in every workflow.
  • Run zizmor against .github/workflows.
  • Enable Dependabot, with a cooldown, and make sure the dependency graph is correct.

A GitHub team in the tryhuset organization should be admin of each repository, not individual people. When someone leaves or changes team, access follows the team and nothing needs to be cleaned up by hand. This is also the team that appears as owner in the product’s Backstage entry.

Protect the default branch so nobody can delete code or push past review and checks.

Go to Settings → Rules → Rulesets → New ruleset → New branch ruleset, add the default branch as a target, and enable:

  • Restrict deletions
  • Block force pushes
  • Require a pull request before merging
  • Require status checks to pass

Workflows run with access to secrets and write permissions on the repository, so a mistake in a workflow can have a big impact. These are the most important measures.

Avoid pull_request_target and workflow_run. Both run with access to secrets and elevated permissions, but can be triggered by anyone who opens a pull request from a fork. If the workflow checks out and runs code from the PR (tests, builds, installs), the attacker’s code runs with the repository’s permissions.

Instead:

  • Use pull_request for anything that runs code from others. It gets no secrets and only read access.
  • If you need both to run code and to do something privileged (e.g. post test results as a PR comment), split it up. Run the tests under pull_request and upload the results as an artifact. Then use a separate workflow_run job that only reads the artifact data, and never executes it, to do the privileged step.
  • Never check out the PR’s head code under pull_request_target.

Never put ${{ ... }} expressions with user-controlled values directly in a run: step. Values like a PR title, branch name or issue body can contain shell commands, which makes the step open to command injection. Set the value in env: and reference it as a shell variable:

- name: Check PR title
env:
TITLE: ${{ github.event.pull_request.title }}
run: |
echo "PR title is $TITLE"

See GitHub’s security hardening guide for more.

Tags are mutable: v1 can point at different code tomorrow than it does today. Pin third-party actions to a full commit SHA, with the version in a comment so Dependabot can keep it updated:

- uses: some-org/some-action@aed4d69de423d70d995a9fac4bb00dedb7b00f91 # v1.2.3

Tools like ratchet or the gh-act CLI extension (gh act pin) can pin existing workflows for you. GitHub’s own actions (actions/*) are immutable and can be referenced by tag.

If a token leaks, the damage is limited if it can only read source code.

  • Set permissions explicitly in every workflow, with read-only defaults.
  • Grant each job only what it needs. A build-and-test job doesn’t need id-token: write, for example.
permissions:
contents: read

zizmor finds many common security problems in GitHub Actions configuration, including the ones above. Run it when setting up or hardening a repository:

Terminal window
zizmor .github/workflows

On public repositories, enable secret scanning and push protection. Push protection blocks pushes that contain secrets, and the scanner also finds secrets that are already in the repository.

On our plan, secret scanning isn’t available for private repositories, so use gitleaks instead. See GitHub Advanced Security. Neither option scans what ends up in built artifacts such as Docker images. See Secrets Management for more.

GitHub uses the dependency graph to raise Dependabot alerts when a dependency has a known vulnerability. Alerts are only as good as the graph, so make sure it actually reflects what the project uses (for example, by committing lockfiles). See Dependency Management.

Use GitHub’s built-in tokens instead of creating your own PATs. In order of preference:

  1. GITHUB_TOKEN for access to the same repository.
  2. GitHub App installation token when you need access across repositories.
  3. PAT only if you have a concrete reason and no better alternative.

GitHub App tokens have several advantages over PATs. They are short-lived (one hour), scoped to only the permissions and repositories the job needs, and they keep working when the person who set them up leaves or has their account deactivated.

  1. Register a new app at https://github.com/settings/apps/new.
  2. Give it a name and a URL.
  3. Disable the webhook unless you need it.
  4. Under Permissions & events, grant only the permissions the app needs.
  5. Under Where can this GitHub App be installed?, choose Only on this account.
  6. Click Create GitHub App, and copy the Client ID (not sensitive).
  7. Under General → Private keys, generate a new key. Store it somewhere safe and delete it from your disk once it has been added where it is used.
  8. Under Advanced → Transfer ownership, transfer the app to the tryhuset organization, and ask a GitHub organization admin to approve the transfer.
  9. Install the app in the tryhuset organization and select only the repositories it needs.

In each repository that uses the app, add the Client ID as a repository variable named CLIENT_ID and the private key as a repository secret named PRIVATE_KEY (Settings → Secrets and variables → Actions).

Token for the current repository:

- uses: actions/create-github-app-token@v3
id: app-token
with:
client-id: ${{ vars.CLIENT_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}

Token for specific repositories:

- uses: actions/create-github-app-token@v3
id: app-token
with:
client-id: ${{ vars.CLIENT_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: |
repo1
repo2

Leave out repositories to get a token for all repositories the app is installed on. Use the token with ${{ steps.app-token.outputs.token }}.

  • CodeQL: scans both application code and GitHub Actions workflows. Only available on public repositories for us, see GitHub Advanced Security.
  • Trivy: scans Docker images, including for secrets baked into the image.
  • Dependabot: keeps dependencies and actions up to date. Regular patching keeps updates small and applications safer.

Adapted from Nav’s security playbook.