CI/CDSupply ChainGitHub Actions

Hardening CI/CD Pipelines and the Software Supply Chain

14 min read
Your CI/CD pipeline runs with privileged access to source code, secrets, and deployment targets — which makes it one of the highest-value targets in your stack. A compromised third-party action or an over-scoped token can turn a routine build into a full supply-chain breach. This guide covers the controls that meaningfully reduce that blast radius: pinning dependencies, scoping permissions, isolating runners, and treating secrets as first-class infrastructure.

Pin Third-Party Actions to a Commit SHA

Referencing an action by a floating tag (uses: actions/checkout@v4) means you run whatever the maintainer — or an attacker who compromises their account — pushes to that tag. Pin every third-party action to a full commit SHA so the code you reviewed is the code that runs. Use Dependabot to propose SHA bumps so pinning does not become a maintenance dead end.

.github/workflows/build.yml
jobs:
  build:
    runs-on: self-hosted-linux
    permissions:
      contents: read
    steps:
      # Pinned to a SHA, with the human-readable tag in a comment
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
      - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
        with:
          node-version: 20
warning

A tag is mutable; a SHA is not. Pinning to @v4 or @main is equivalent to running unreviewed code on every push with access to your secrets.

Scope GITHUB_TOKEN to Least Privilege

By default the automatic GITHUB_TOKEN may carry broad write permissions. Declare an explicit, minimal permissions block at the workflow or job level so a compromised step cannot push code, publish packages, or alter releases. Grant write scopes only on the specific jobs that need them, and prefer short-lived OIDC tokens over long-lived cloud credentials stored as secrets.

  • Set permissions: contents: read at the top of every workflow as the default
  • Elevate to write only on the specific job that needs it (e.g. a release job)
  • Use OIDC federation to your cloud provider instead of static access keys
  • Never expose secrets to workflows triggered by pull_request from forks
  • Avoid pull_request_target unless you fully understand its access to secrets

Isolate and Control Your Runners

Where your jobs execute matters as much as what they execute. Self-hosted runners give you control over the network egress, base image, and isolation boundary of the build environment — at the cost of having to keep them patched and ephemeral. Prefer single-use (ephemeral) runners that are destroyed after each job so a compromised build cannot persist on the runner or harvest the next job's secrets.

tip

Run untrusted or fork-originated workloads on disposable, network-restricted runners. Reserve runners with deploy credentials for trusted, post-merge jobs only.

Keep Secrets Out of Logs and History

Pipelines leak secrets through echoed environment variables, debug output, and committed configuration. Inject secrets at runtime from a dedicated secrets manager rather than committing them, mask them in logs, and enable push protection / secret scanning so a credential that does slip into a commit is caught before it ships. Rotate any secret that has ever been printed to a build log — assume it is compromised.

  • Enable GitHub secret scanning and push protection on every repository
  • Inject secrets from a manager at run time; never commit them to the repo
  • Treat any secret printed to a log as compromised and rotate it
  • Audit workflow run logs for accidental echo of tokens and keys