DEV Community

Lucky
Lucky

Posted on

Your CI Pipeline Is the Most Privileged Machine You Own. You Have Never Scanned It.

You scan your application code. You scan your dependencies. You scan your containers.

You have never scanned the thing that has access to all of them.

Your CI pipeline holds your deploy keys. Your registry credentials. Your cloud tokens. Your signing secrets. It runs on every push, it runs third-party code you did not write, and it runs with more privilege than any developer on your team.

It is the most privileged machine you own. And for most projects, it is completely unreviewed.


Why Attackers Target The Pipeline

An attacker who compromises your laptop gets your laptop.

An attacker who compromises your pipeline gets production.

They do not need to find a vulnerability in your code. They do not need to phish a developer. They need one workflow file that trusts something it should not. The pipeline then does the work for them, with credentials you handed it, inside a network you allowed.

This is not theoretical. Supply chain attacks moved to CI years ago because that is where the credentials live. The Codecov bash uploader compromise in 2021 exposed secrets from thousands of CI environments. In March 2025, the widely used tj-actions/changed-files action was compromised and began leaking secrets into build logs of every repository that referenced it by tag.

Both attacks worked the same way. Trusted third-party code, running with pipeline privileges, on somebody else's push.


1. Actions Pinned To Tags Instead Of Commits

The mistake: Your workflow says uses: some-org/some-action@v3.

A tag is a pointer. Whoever controls the repository controls where it points. If that account is compromised, v3 silently becomes attacker code on your next run. You changed nothing. You approved nothing. You pulled it anyway.

The fix: Pin every third-party action to a full commit SHA.

# Do not do this
uses: some-org/some-action@v3

# Do this
uses: some-org/some-action@a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
Enter fullscreen mode Exit fullscreen mode

A SHA cannot be moved. Add a comment with the version so future you knows what it was.

How to catch it: grep -rn "uses:.*@v" .github/workflows/ lists every unpinned action you have. Do it now. The result is usually worse than expected.


2. Secrets Available To Every Job

The mistake: Your secrets are repository-wide. Every workflow can read them. That includes the lint job. That includes the job that runs a third-party formatter on a fork's pull request.

A secret is exposed to everything that can read it, not just to the step you were thinking about when you added it.

The fix: Scope secrets to environments, not to the repository, and require approval for the environments that hold production credentials. A test job does not need a deploy key. Give it nothing.

How to catch it: List your repository secrets. For each one, name the single job that needs it. Anything you cannot assign to a specific job is over-shared.


3. Workflows That Run On Untrusted Pull Requests

The mistake: You use pull_request_target because you needed a token that pull_request does not have, and then you check out the pull request head.

That combination runs attacker-controlled code with your secrets in scope. It is the single most reliable way to leak credentials from a public repository.

The fix: Use pull_request for anything that touches untrusted code. If you genuinely need privileged work on a fork's contribution, split it: an unprivileged job builds and uploads an artifact, a separate privileged job consumes the artifact without ever checking out the fork's code.

How to catch it: grep -rn "pull_request_target" .github/workflows/. Every hit needs a written justification. If nobody remembers why it is there, it should not be there.


4. Default Write Permissions On The Job Token

The mistake: You never set permissions:, so the automatic token gets whatever the repository default is. On older repositories, that default is write. Any compromised step in that job can push commits, edit releases, or open pull requests as your CI.

The fix: Default to nothing and grant upward per job.

permissions:
  contents: read

jobs:
  build:
    permissions:
      contents: read
  release:
    permissions:
      contents: write
Enter fullscreen mode Exit fullscreen mode

How to catch it: Any workflow file with no permissions: block is running on defaults. Search for the files that lack it, not the ones that have it.


5. Secrets Printed Into Logs

The mistake: Someone added set -x while debugging, or a step echoes a config object, or a failing command dumps its environment. Masking catches exact string matches. It does not catch a secret that has been base64 encoded, JSON wrapped, or split across a variable expansion.

Build logs on public repositories are public. Artifacts are downloadable.

The fix: Never echo configuration. Remove debug tracing before merge. Treat log output as published, because it is.

How to catch it: Run a secret scanner across your own build logs and artifacts, not just your source tree. Gitleaks and TruffleHog work on any text. Most people never point them at the one place secrets actually get printed.


The Ten Minute Audit

You do not need a new tool for this. Open a terminal in your repository:

# Unpinned third-party actions
grep -rn "uses:.*@v" .github/workflows/

# Workflows that run untrusted code with privileges
grep -rn "pull_request_target" .github/workflows/

# Workflows with no explicit permissions block
grep -rLn "permissions:" .github/workflows/*.yml

# Debug tracing left behind
grep -rn "set -x" .github/workflows/
Enter fullscreen mode Exit fullscreen mode

Four commands. Ten minutes. Fix the SHAs first, then the permissions blocks. That covers most of the realistic attack surface.


Why This Stays Broken

Pipeline security falls between two teams. Application security scanners read your source code, not your workflow files. Infrastructure scanners read your Terraform, not your build steps. The pipeline sits in the gap, and the gap is where the credentials are.

The tools that do read workflow files exist. Checkov covers CI configuration, Semgrep has rules for GitHub Actions, and actionlint catches the structural problems. None of them is the default in a new repository, which is exactly why this keeps working for attackers.


The Bottom Line

Your pipeline has more privilege than you do. Scan it like it does.

Pin your actions to SHAs. Scope your secrets. Never run untrusted code with credentials in reach. Then go look at what your build logs have been printing this whole time.


Discussion Question

Go run grep -rn "uses:.*@v" .github/workflows/ right now. How many unpinned actions did you find? I want to know if anyone is actually at zero.

Top comments (0)