GitHub Actions Security Hardening: CI/CD Supply Chain Attack Prevention
GitHub Actions is a frequent target for CI/CD supply chain attacks: secrets leakage, third-party action poisoning, and pull_request_target abuse can lead to production takeover. This guide covers least-privilege permissions, secrets protection, pinning third-party actions, and self-hosted runner isolation, with hardening configs you can apply directly.
Scope
GitHub Actions security hardening applies in these cases: the team uses GitHub Actions to build, test, and deploy production code; the repository holds database passwords, cloud vendor keys, API tokens, or other credentials; CI depends on third-party actions or custom scripts; pipelines run on self-hosted runners. Supply chain attacks have been frequent in recent years. Attackers poison popular actions, steal secrets, or hijack runners for lateral movement. The CI/CD pipeline has become a shortcut into production. This article provides copy-paste hardening configs.
Prerequisites
- A GitHub repository (public or private) with Admin permission
- GitHub Actions enabled (Settings → Actions → General)
- For self-hosted runners, a dedicated Linux VM or container
Mechanism
GitHub Actions supply chain attacks exploit the trust chain: third-party actions referenced in workflows, runtime environment variables, secrets injection, and runner privileges. Four common paths: (1) action poisoning—attackers push malicious code into a popular action or hijack a dormant account (e.g. 2021 CVE-2021-32688 against tj-actions/changed-files); (2) secrets leakage—malicious scripts steal repository secrets via environment variables or log output; (3) pull_request_target abuse—untrusted PR code runs in a context that has secrets; (4) compromised self-hosted runners—leftover secrets and caches on the runner are read by the attacker. Hardening approach: least privilege, isolate sensitive contexts, pin dependency versions, encrypt credentials at rest.
Procedure
1. Minimize workflow permissions
In repository Settings → Actions → General → Workflow permissions, set the default to Read repository contents and disallow Actions from creating or approving PRs. In each workflow, declare required permissions explicitly with the `permissions` field:
name: build
on: [push]
permissions:
contents: read # read repository contents only
packages: read # read packages only
issues: none # issues permission not needed
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
2. Secrets protection
Store sensitive values in Settings → Secrets and variables → Actions. Do not hardcode them in the repository. Environment-level secrets inject only into the specified environment, so development branches cannot reach production credentials:
# environment-level secrets example
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # only production environment has DEPLOY_KEY
steps:
- name: Deploy
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
run: ./deploy.sh
Key parameter: `environment` scopes secrets to that environment. Combined with environment protection rules (require review before deploy), this cuts the leak surface substantially.
3. Pin and verify third-party actions
When referencing third-party actions, do not use floating branches such as `@main` or `@master`. Pin to the full commit SHA and enable Dependabot auto-updates:
# unsafe
- uses: actions/checkout@main
# safe: pin to commit SHA
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
Also restrict allowed action sources: Settings → Actions → General → Actions permissions, choose Allow select actions, and allow only official GitHub actions plus a repository allowlist.
4. Prevent pull_request_target abuse
`pull_request_target` runs in the default-branch context and can access secrets. If you must use it, put untrusted PR code in a separate step with least privilege, and do not checkout and execute build scripts from the PR:
# dangerous: checkout and run PR code
on: pull_request_target
jobs:
comment:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
# never execute scripts from PR code here!
# safe: run fixed logic in default-branch context only
on: pull_request_target
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Comment PR
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Review passed'
})
5. Isolate self-hosted runners
Self-hosted runners sit on the corporate network and are a lateral-movement jump box. Deploy a separate runner per project, run it as a dedicated low-privilege account, and never reuse root. Do not store credentials for other services on the runner host. Restrict egress (allow only domains required for the build). Periodically clean `_work` and caches on the runner:
# create dedicated runner user
sudo useradd -m -s /bin/bash runner-user
# clean historical build cache
rm -rf /home/runner-user/actions-runner/_work/*
rm -rf /home/runner-user/actions-runner/_diag/*
# restrict egress (allow only required domains)
sudo iptables -A OUTPUT -d github.com -j ACCEPT
sudo iptables -A OUTPUT -d api.github.com -j ACCEPT
sudo iptables -A OUTPUT -j DROP
6. Audit and monitoring
Enable the repository Audit log (Enterprise), review Secrets scanning alerts regularly, and ship Actions run logs to a logging platform. Put branch protection on workflow file changes so PRs must be reviewed before merge. This stops attackers from injecting malicious code by editing `.github/workflows/` directly.
Verification
# 1. Push a test branch to trigger the workflow; confirm permissions take effect
git push origin test
# 2. On the Actions page, inspect permissions for each run
# Open run details → Job summary, check that "GITHUB_TOKEN Permissions" contains only declared items
# 3. Verify secrets are not leaked: grep key strings in logs
# If secret values appear in Actions logs, the config is wrong; revoke and rotate immediately
FAQ
Q1: After pinning to a commit SHA, how do you upgrade a third-party action?
Enable Dependabot (Settings → Security → Dependabot alerts). It opens upgrade PRs for actions pinned to old SHAs. In the upgrade PR, manually review the action changelog (especially the diff), confirm there is no malicious change, then merge. Do not blindly click Update branch.
Q2: Are secrets safe? Why is storing secrets in GitHub not absolute safety?
Secrets are encrypted at rest on GitHub. Once a workflow references `${{ secrets.XXX }}`, they are injected in plaintext into the runner process environment. Any malicious code in that step can read them. Storage location is not enough: restrict who can trigger secret access in which contexts (environment, branch, PR), and set secret expiry and rotation.
Q3: Extra risk for public repositories using GitHub Actions?
On a public repo, any forked PR can trigger workflows (if `pull_request` is configured). Attackers can submit malicious workflow changes in a PR or exploit bugs in existing workflows. For public repos, disable automatic `pull_request` triggers, enable Require approval for first-time contributors, and strictly control the third-party action allowlist.
Summary
GitHub Actions hardening is least trust: declare permissions as needed, isolate secrets by environment, pin actions to SHA, isolate runners, and require review for workflow changes. These five steps are low cost and block most CI/CD supply chain attacks. Put the checks in the team security baseline and review quarterly.