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.
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.
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.
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.
Related
- Supply Chain & Dependency Security — the parent guide covering the full pipeline threat model
- Pinning GitHub Actions to a Commit SHA — the mechanics and the update workflow
- OIDC Federation Instead of Long-Lived Cloud Keys — replacing static credentials end to end
- Verifying Container Image Signatures With Cosign — proving that what deploys is what was built
- Dependency Scanning & CI Security Gates — the vulnerability gate that runs inside this hardened pipeline