Your Code Is Secure. Is Your Build Pipeline?

In my post From GitHub Repo to Live Website (and Then Auto-Deploy), I built a workflow that deploys my site on every push. Magic. Then I stopped and asked myself:
Who else can make that workflow run?
A GitHub Actions workflow is more than an automation script. It can execute code, access repository secrets, interact with external services, and receive permissions to modify your repository. If an attacker finds a way to influence what it executes, the impact can extend far beyond the application itself.
Why attackers like CI/CD?
A pipeline is a machine that:
Runs code someone wrote
Holds secrets (cloud keys, deploy tokens)
Can push to your repo, packages, or production
If an attacker controls what the pipeline runs, they inherit everything it holds. This is not theoretical.
1. Workflow permissions: the GITHUB_TOKEN
The permissions assigned to GITHUB_TOKEN depend on your repository or organization's settings. GitHub allows administrators to configure the default as either permissive or restricted, and repository settings may inherit the organization-level policy.
Check yours at Settings → Actions → General → Workflow permissions.
A workflow that receives unnecessary write permissions can potentially modify repository contents, create releases, or interact with pull requests. If a third-party action or script is compromised, those permissions can become an attacker’s opportunity.
The safer approach is to explicitly define the permissions each workflow and job requires.
2. Secret exposure: how secrets actually leak
GitHub encrypts repository secrets at rest, but encryption does not protect a secret after a workflow makes it available to a running process.
A secret passed through the env context becomes accessible to that process and potentially to child processes it launches. If untrusted code executes in the same environment, it may be able to read and expose that value.
Here are some common ways secrets can escape a workflow’s intended security boundary:

3. The pull_request_target trap (controlled demo)
There are two PR triggers, and they behave very differently:

pull_request: For forked PRs, GitHub normally provides a read-only GITHUB_TOKEN and does not expose repository secrets.
pull_request_target: Runs in the context of the base repository. It can access repository secrets and receive a token with elevated permissions, including write access.
This makes pull_request_target useful for trusted maintenance tasks such as labeling or commenting on external PRs. However, it becomes dangerous when developers use it to check out and execute code submitted by an untrusted contributor.
That mistake is commonly known as a pwn request.
The vulnerable workflow
# .github/workflows/pr-test.yml (INSECURE)
name: PR tests
on: pull_request_target
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # untrusted code!
- run: npm ci && npm test
env:
DEMO_SECRET: ${{ secrets.DEMO_SECRET }}The “attack”: From a fork, the PR changes only package.json:
"scripts": {
"test": "echo $DEMO_SECRET | base64"
}An attacker opens a pull request from a fork.
The workflow is triggered using pull_request_target, so it runs in the base repository's privileged context.
actions/checkout override the default trusted checkout and retrieve the attacker's commit using github.event.pull_request.head.sha.
npm ci and npm test execute commands influenced by the attacker's repository.
The DEMO_SECRET environment variable is available to those commands.
The workflow is therefore executing attacker-controlled code in an environment that has access to a repository secret.
The problem isn’t simply that someone opened a pull request. It is the combination of a privileged trigger, an untrusted checkout, and execution of that code.
4. The fix: least privilege
Start from “deny everything”, then add only what each job needs.
Fixed workflow:
name: PR tests
on: pull_request # no secrets for fork PRs, read-only token
permissions: {} # deny all by default
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read # only what this job needs
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- run: npm ci && npm testChecklist:
Use pull_request for anything that builds or tests PR code.
If you need pull_request_target (labeling, commenting), never check out or run the PR's code in it.
If a privileged step must follow untrusted work, split into two workflows: an unprivileged one builds and uploads an artifact, and a workflow_run one with permissions consumes it, treating the artifact as untrusted data.
Set permissions: {} at the top, then grant per job.
Pin third-party actions to a full commit SHA, not a tag:
- uses: actions/checkout@<full-40-character-commit-sha> # v4.x.xTags can be moved. SHAs can’t.
Use environments with required reviewers for production secrets.
Prefer OIDC over long-lived cloud keys, so there is no stored secret to steal.
Turn on Dependabot for github-actions so pinned SHAs still get updated.
5. Scan your workflows
Don’t rely on eyeballing YAML. Run these:

Try running Zizmor against the vulnerable workflow from the previous section.
Pay particular attention to findings related to dangerous triggers, checkout of untrusted code, and excessive permissions.
After fixing the workflow, run the scanner again and review the findings. A clean scan does not guarantee that a workflow is secure, but it gives you another layer of protection beyond manual review.
pip install zizmor
zizmor .github/workflows/Run it on the vulnerable workflow above and it should flag the pull_request_target checkout and the missing permissions. Then add it to CI so every change to a workflow is scanned.
Secure code is only half the story. Your pipeline decides what actually reaches production, and it holds the keys to get there.
So, give it the same care as the app: a read-only token by default, no untrusted code near your secrets, pinned actions, and a scanner that checks every workflow change. None of this takes long, and all of it is cheaper than rotating every key after a breach.
Your code can be secure and your build pipeline still open. Check the pipeline too. I’m still learning this one step at a time, so if you spot something I missed, tell me.
Everything here, from the pipelines to the permissions, is what I use to build and protect HetAas Atelier, my resin art shop. Artistic and technical knowledge, working together.



Comments