Hardening CI/CD Pipelines and the Software Supply Chain
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.
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: 20A 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.
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