Your CI Pipeline Has More Access Than Your Engineers Do
A build system with production credentials and the ability to run arbitrary code from a pull request is the most under-secured part of most stacks.
Disclosure: I run an infrastructure company that sells isolated environments, including for CI. The controls below mostly cost nothing and do not require buying anything.
Most teams put real effort into access control for people. Reviews, least privilege, MFA, audit logs.
Then the CI system gets a long lived credential with broad permissions, runs code from any branch, and nobody has looked at it since it was set up.
Why this is the soft target
Three properties combine badly.
It holds production credentials. Deploy keys, cloud credentials, registry tokens, database access for migrations. It has to, to do its job.
It executes arbitrary code. Every build runs whatever is in the repository, including whatever a pull request added. Test scripts, build scripts, and install hooks in dependencies all execute.
Nobody watches it. There is rarely an audit log anyone reads, alerting on unusual activity, or a review of what permissions it actually has.
A credential that can deploy to production, held by a system that runs untrusted code, unmonitored, is a genuinely large exposure and it is normal.
The specific attack paths
Pull requests from forks
The classic. A workflow triggered by pull_request_target runs with access to your secrets and checks out the fork's code.
# dangerous
on: pull_request_target
jobs:
test:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # attacker's code
- run: npm test # with secrets available
Anybody who opens a pull request can run code with your secrets. pull_request_target exists for legitimate reasons and it should never check out and execute the untrusted head.
Use pull_request for anything that runs contributor code. It has no secrets by default, which is the correct default.
Install scripts
npm install runs postinstall from every package in the tree. pip install runs setup.py. A compromised or typosquatted dependency executes with whatever the runner has.
npm ci --ignore-scripts
This breaks packages needing native compilation, so it is not universal, and where it works it removes a large surface. Combine with a lockfile and frozen installs so a new dependency cannot appear without a reviewed diff.
Secrets in logs
- run: |
curl -H "Authorization: Bearer $TOKEN" https://api.example.com
with set -x enabled, or a script that echoes its environment on error, prints the token to a log that may be readable by anyone with repository access.
Most CI systems mask registered secrets in output, and masking only works on exact string matches. A base64 encoded secret, or one split across lines, or a derived value, will not be masked.
A malicious action or plugin
- uses: some-org/some-action@v1
That is a floating tag. The author, or anybody who compromises their account, can move it to point at different code.
- uses: some-org/some-action@a1b2c3d4e5f6... # full commit sha
Pinning to a sha means the code cannot change under you. Dependabot can still propose updates, which then go through review.
The controls that matter
Use short lived credentials
The single biggest improvement available.
OIDC federation lets your CI exchange a signed identity token for temporary cloud credentials, with no long lived secret stored anywhere:
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-deploy
aws-region: ap-south-1
The role's trust policy restricts which repository and which branch can assume it:
{
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:ref:refs/heads/main"
}
}
}
Now a credential cannot leak because there is no credential. A pull request branch cannot assume the role because the subject does not match. This is well supported across AWS, GCP, Azure, and HashiCorp Vault, and it is worth the afternoon it takes.
Scope secrets to environments
Do not put production credentials in repository level secrets available to every workflow.
Use environment scoped secrets with required reviewers, so a deploy to production pauses for approval and the credential is only injected for that job.
jobs:
deploy:
environment: production # requires approval, has its own secrets
Separate the pipelines
Build and test do not need deploy credentials. Deploy does not need to run tests.
Two workflows: one that runs on every push and pull request with no secrets, and one that runs only on main or on a tag, with credentials, and which does not execute repository code beyond a deploy command.
The second one ideally deploys an artifact built by the first, verified by digest, rather than rebuilding from source. That way the privileged step is not running a build script at all.
Least privilege on the token
permissions:
contents: read # default to minimum
GitHub's default token permissions are broad unless you restrict them at the organisation level. Set the default to read only and grant more per job where needed.
Same principle for cloud roles. A deploy role that can update one service does not need administrator access, and the instinct to grant broad permissions "to avoid problems" is how a CI compromise becomes an account compromise.
Isolate the runner
Self hosted runners are the highest risk configuration, because a compromised job persists on the machine and affects subsequent jobs.
If you use them:
Ephemeral runners. A fresh environment per job, destroyed after. This removes persistence entirely and is the single most important property.
Never use self hosted runners on public repositories. Anyone can open a pull request and run code on your infrastructure. This is stated in the documentation and it still happens.
Network restrictions. A runner that only needs your registry and your cloud API does not need general internet egress. Restricting it blocks a large fraction of exfiltration paths, the same reasoning as egress control for agent workloads.
This is the same isolation question as running untrusted code generally: a container is adequate against a mistake, and a shared kernel between jobs from different trust levels is a weaker boundary than people assume.
Detection
Prevention is not complete, so know when something is wrong.
Alert on secret access outside expected workflows. Most cloud providers log credential use. A deploy role assumed at 3am from an unusual workflow is worth a page.
Scan for committed secrets on every push, with gitleaks or equivalent. It catches the accident before it reaches the log.
Review the audit log periodically. Who changed workflow files, who added secrets, who modified environment protection rules. Workflow file changes in particular deserve the same review scrutiny as application code, and they usually get less because they look like configuration.
Rotate anything long lived on a schedule. If you must keep a static credential, rotating it bounds how long a leak is useful.
A short audit
Worth an hour on any pipeline you have inherited:
- List every secret and who or what can read it
- For each, ask what it can do and whether it needs to
- Check for
pull_request_targetwith a checkout of the head ref - Check whether actions are pinned by sha or by tag
- Check default token permissions
- Check whether self hosted runners are ephemeral
- Check whether build and deploy share credentials
- Find one long lived credential and replace it with OIDC
Most teams find something in the first three steps.