Build Pipeline & CI Runner Hardening

The pipeline builds and deploys production, holds credentials for every environment, and runs code from hundreds of third parties on every push. In most organisations it receives a fraction of the scrutiny that production itself gets, which is why supply chain attacks aim at it: compromising a build is more efficient than compromising a running service, because whatever the build produces is trusted downstream by construction.

This guide hardens the pipeline as the production system it is: immutable references for every third-party step, federated short-lived cloud credentials, install-time script execution disabled, secrets kept away from untrusted builds, and artifacts signed so that what deploys is provably what was built. It is part of Supply Chain & Dependency Security, and it complements the dependency controls in dependency scanning and CI security gates.


Threat Anatomy

A pipeline compromise has four common entry points, and each one is mundane.

A movable reference is the simplest: a workflow references a third-party step by tag, the tag is repointed to malicious content, and every build in the world running that tag executes it. An install-time script achieves the same thing one layer down, because dependency installation runs code from every package in the graph with the runner’s full environment available. A secret exposed to an untrusted build hands over credentials directly — a workflow that runs on pull requests from forks with deployment secrets in scope is a gift. And a long-lived cloud key simply outlives its containment: it leaks into a log, a fork, or a developer machine, and remains valid until someone notices.

Four Ways Into a Build, and What Closes Each A movable third-party reference grants code execution in every build and is closed by digest pinning. An install-time script grants the same with the runner environment attached and is closed by disabling lifecycle scripts. A secret exposed to an untrusted build grants credentials directly and is closed by separating trusted and untrusted triggers. A long-lived cloud key grants persistent access and is closed by federated short-lived identity. Entry point What it grants Control that closes it Movable action or image tag code execution in every build pin by immutable digest Install-time lifecycle script execution before any scan runs install with scripts disabled Secret in an untrusted build credentials, handed over directly split trusted and untrusted triggers Long-lived cloud key persistent access after a leak federated short-lived identity

Prerequisites & Scope

  • Workflow definitions in version control, reviewed like application code.
  • A cloud provider that supports workflow identity federation, or a secrets broker that issues short-lived credentials.
  • Branch protection on the branches that deploy, including for administrators.
  • An artifact registry that supports signatures and attestations.
  • Automated update tooling capable of proposing digest bumps, so pinning does not become staleness.

Out of scope: the dependency-vulnerability gates covered by dependency scanning and CI security gates, and the inventory work covered by software bill of materials.


Mitigation Architecture

Layer Hardened pattern Failure it prevents
Third-party steps Referenced by digest, updated by tooling A repointed tag executing new code in every build
Dependency install Lifecycle scripts disabled, exceptions named Package code running with runner credentials
Credentials Federated, short-lived, scoped to repository and branch A leaked key remaining valid indefinitely
Trigger separation Untrusted triggers get no secrets and no write scope A fork pull request exfiltrating deployment access
Runner Ephemeral, isolated per repository Cross-job contamination and lingering state
Output Signed, with provenance attestation An artifact swapped between build and deploy

Step-by-Step Implementation

Step 1 — Pin every third-party step by digest (SLSA build L2, NIST SSDF PW.4)

jobs:
  build:
    runs-on: ubuntu-24.04
    steps:
      # Pinned to content, with the human-readable version kept in a comment.
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683   # v4.2.2
      - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
        with: { node-version: '22.11.0' }
      - name: Build in a pinned container
        run: docker run --rm ghcr.io/example/builder@sha256:9f2c…  make build

The comment keeps the reference legible; the digest keeps it honest. Configure your update tooling to raise pull requests that bump both together, so pinning is a review checkpoint rather than a reason to fall behind.

Step 2 — Replace static cloud keys with federated identity (SSDF PO.5)

permissions:
  id-token: write        # mint a workflow identity token
  contents: read         # nothing else, by default

jobs:
  deploy:
    steps:
      - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
        with:
          role-to-assume: arn:aws:iam::123456789012:role/deploy-web
          aws-region: eu-west-1
          # No access key, no secret key — the workflow identity is the credential.

The trust policy on the cloud side is where the scoping actually happens: restrict it to the exact repository, the exact branch or environment, and the workflow file. A policy that trusts any workflow in the organisation converts one compromised repository into access for all of them.

Step 3 — Install dependencies without running their code (SSDF PW.4.1)

- name: Install without lifecycle scripts
  run: |
    npm ci --ignore-scripts
    # Allow only packages that genuinely need a build step, by name.
    npm rebuild sharp better-sqlite3
# Python equivalents: prefer wheels, and never let pip run arbitrary setup code silently.
pip install --require-hashes --only-binary :all: -r requirements.lock

This single flag removes the most direct path from a compromised package to your runner. The rebuild list is the reviewable exception, and it should be short enough to read.

Step 4 — Keep untrusted triggers away from secrets (SSDF PS.1)

# Untrusted: runs on any fork's pull request. No secrets, no write permissions.
name: pr-checks
on: pull_request
permissions: { contents: read }
jobs:
  test:
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
      - run: npm ci --ignore-scripts && npm test          # no deployment credentials in scope

# Trusted: runs after merge, or on a protected environment with approval.
name: deploy
on:
  push: { branches: [main] }
permissions: { id-token: write, contents: read }
environment: production        # required reviewers gate this job

The rule is short: a workflow that can be triggered by someone outside the organisation must never hold a credential that matters. Where a privileged action genuinely has to run for a pull request — publishing a preview environment, say — put it behind an environment with required reviewers, so a human sees the code before the credential is issued.

Two Workflows, Two Very Different Trust Levels The untrusted workflow runs on pull requests from any fork, holds read-only permissions and no secrets, and runs tests with dependency scripts disabled. The trusted workflow runs only after merge or behind an approved environment, mints a short-lived federated credential, and performs the deployment. Nothing crosses between them except a reviewed merge. Untrusted — pull request from a fork code is whatever the contributor wrote permissions: contents read, nothing more secrets in scope: none at all install runs with lifecycle scripts disabled Worst case: the contributor learns what your tests print, which is acceptable. Trusted — after merge or with approval code has passed review and branch protection permissions: identity token, plus what it needs credential: federated, minutes long, scoped environment: required reviewers on production Worst case: a reviewed change deploys — which is what the pipeline is for.

Edge Cases & Bypass Patterns

Workflow files edited in the pull request itself. On some platforms a contributor can modify the workflow that will run on their own change. Require workflow changes to come from branches in the repository, or gate them behind an approval that is separate from ordinary code review.

Four Ways Around a Hardened Pipeline A contributor editing the workflow that will run on their own change. A cache written by an untrusted job and restored into a trusted one. A pinned composite step that references further steps by tag internally. And a self-hosted runner inside a private network, where a single malicious build reaches infrastructure the pipeline was never meant to touch. Workflow edited in the same change the definition that runs is the one the contributor just wrote Cache as a transport written by an untrusted job, restored into one holding credentials Composite step, unpinned inside your pin covers the entry point, not the references it makes internally Self-hosted runner with routes a foothold inside the network, reachable by whatever the build decides to run

Caches as a transport. A build cache written by an untrusted job and read by a trusted one is a channel from one to the other. Namespace caches by trust level, and never restore a cache into a job holding credentials if an untrusted job could have written it.

Composite steps that hide their own references. A pinned action can internally reference other actions by tag, so the pin you can see is not the whole graph. Prefer steps whose sources you can inspect, and periodically resolve the full graph to confirm nothing floats.

Self-hosted runners with network reach. A runner inside a private network is a foothold with routes. Make them ephemeral, scope them to a single repository, deny them access to anything they do not build, and keep untrusted triggers off them entirely.


Automated Testing & CI Validation

- name: Fail on unpinned third-party steps
  run: |
    if grep -REn 'uses:\s+[^ ]+@(v?[0-9]+([.][0-9]+)*|main|master)\s*$' .github/workflows/; then
      echo "::error::Every third-party step must be pinned to a commit digest."
      exit 1
    fi

- name: Fail on static cloud credentials in workflow files
  run: |
    if grep -REn 'AWS_(ACCESS_KEY_ID|SECRET_ACCESS_KEY)|GOOGLE_APPLICATION_CREDENTIALS' .github/workflows/; then
      echo "::error::Use federated identity rather than static keys."
      exit 1
    fi

- name: Fail when an untrusted trigger has write permissions
  run: python3 tools/check_workflow_permissions.py .github/workflows/

These are cheap, deterministic checks that catch the regressions people actually make — a hurried workflow edit that reverts a pin, or a copied snippet carrying static keys.


Where the Blast Radius Actually Lives

Hardening effort is best spent where a compromise reaches furthest, and in a pipeline that is rarely the place teams look first. Three questions locate it quickly.

What credentials does this workflow hold, and what can they reach? A deployment role scoped to one bucket prefix is a very different exposure from an administrative role that was granted once during an outage and never narrowed. Enumerate the permissions each workflow can actually exercise, not the permissions it was intended to have — cloud audit logs make this concrete in a way that policy documents do not.

What runs before the first security check? Dependency installation, container base image pulls and third-party setup steps all execute before any scanner or test has looked at anything. Everything in that window runs with the runner’s full environment. Shrinking the window — installing without lifecycle scripts, pulling images by digest, deferring credential exposure until after the untrusted work is done — is usually cheaper than any control applied afterwards.

Who can trigger this workflow? A workflow triggered only by a merge to a protected branch has already passed review. One triggered by a comment, a label, a schedule reading an external source, or a pull request from a fork has not. Map every trigger to the trust level of whoever can fire it, and align the credentials available in each case with that level rather than with convenience.

The output of those three questions is usually a short list, and it is almost never the list a team predicts. The most commonly overlooked entry is a workflow nobody thinks of as a deployment — a documentation build, a preview environment, a nightly report — that happens to hold a credential broad enough to matter, because it was easier to reuse an existing secret than to create a narrow one.

Fixing that list first buys more than a complete implementation of every control in this guide applied uniformly, because pipeline compromise is not a volume problem. One workflow with an over-broad credential is the whole exposure.

Compliance Mapping

Framework Control Satisfied By
SOC 2 CC8.1 — change management Branch protection, reviewed workflow changes, approval-gated environments
SOC 2 CC6.1 — logical access Federated short-lived credentials scoped per repository and branch
NIST SSDF PW.4 — reuse of well-secured software Digest-pinned steps and images with automated update proposals
NIST SSDF PO.5 — secure build environments Ephemeral isolated runners, no secrets in untrusted triggers
SLSA Build level 2 Signed provenance attestation for each published artifact
ISO 27001 A.8.31 — separation of environments Trust-level separation between pull-request and deployment workflows

Common Pitfalls Checklist


Frequently Asked Questions

Why pin to a digest when a version tag already looks specific?

Because a tag is a pointer that somebody else controls. Whoever owns the repository or registry can move a version tag to different content at any time, and a compromised maintainer account is precisely the scenario supply chain attacks exploit. A digest names content rather than a label, so the step you reviewed is the step that runs. Automated update tooling can still propose digest bumps, which keeps you current without giving up immutability.

Are self-hosted runners safer than hosted ones?

Only if you treat them as production hosts. Hosted ephemeral runners start clean for every job, which eliminates a whole class of cross-job contamination. A long-lived self-hosted runner accumulates state, usually sits inside a private network, and often holds cloud credentials, so one malicious build reaches much further. If you need self-hosted runners for network access, make them ephemeral, isolate them per repository, and keep untrusted pull requests off them entirely.

What is the biggest practical win if we can only do one thing?

Remove long-lived cloud credentials and replace them with short-lived federated identity scoped to a repository and branch. Static keys leak through logs, forks, misconfigured workflows and personal machines, and they stay valid until somebody notices — which is usually after the incident. Federation makes a leaked credential expire in minutes and useless outside the exact workflow it was minted for.

Do install-time scripts really need to be disabled?

In continuous integration, yes. Lifecycle scripts execute arbitrary code from every package in the resolved graph, with the runner’s environment and credentials available, before any test or scanner has looked at anything. Install with scripts disabled, and where a package genuinely requires one — native compilation being the common case — allow it explicitly by name so the exception is visible in review rather than implied by a default.