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
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
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/
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)