CI/CD pipeline security: where attackers love to hide
, 5 min read
A CI/CD pipeline has what an attacker wants in one place. It runs code in a trusted environment. It holds cloud credentials, registry tokens and signing keys. It can reach production. And whatever it produces is deployed without anyone checking again. That combination is why attackers target pipelines directly.
Three incidents worth knowing
Codecov, 2021. From 31 January 2021, an attacker changed Codecov's Bash Uploader, a script many CI jobs downloaded and ran on every build. The change sent the job's environment variables to a server the attacker controlled, and those usually include CI secrets. Codecov found it on 1 April and disclosed it on 15 April. Codecov traced the root cause to an error in its Docker image build that exposed the credential needed to change the script.
tj-actions/changed-files, March 2025 (CVE-2025-30066). An attacker pointed the version tags of this popular GitHub Action at a malicious commit. Any workflow that referenced the action by tag ran code that dumped the runner's secrets into the workflow log. On public repositories, anyone can read those logs. CISA reports that reviewdog/action-setup was compromised the same week (CVE-2025-30154). The fixed version is v46.0.1.
Trivy, March 2026 (CVE-2026-33634). Attackers with stolen credentials force-pushed 76 of the 77 version tags of aquasecurity/trivy-action, and all seven tags of setup-trivy, to credential-stealing code. They also published a malicious Trivy v0.69.4 release. According to Aqua's advisory, the trivy-action tags were bad for about 12 hours on 19–20 March. The payload read runner memory and swept the file system for SSH keys, cloud credentials and tokens. The irony is that this was a security scanner. Many pipelines ran it with broad access precisely because it's a security tool.
In all three, the victims' own code was fine. What they pulled in wasn't.
Pin third-party actions to a commit SHA
A tag like @v4 or @master is a pointer the action's owner, or anyone who steals their credentials, can move. GitHub's security guidance says pinning to a full-length commit SHA is the only way to use an action as an immutable release:
# Mutable: whatever the tag points to today
- uses: some-org/some-action@v4
# Immutable: exactly this commit, with the version as a comment
- uses: some-org/some-action@<full 40-character commit SHA> # v4.2.1Dependabot and Renovate can both keep SHA pins up to date, so pinning doesn't mean freezing. Aqua's advisory adds one caveat: some older trivy-action commits fetched setup-trivy by tag internally, so a pin only protects you as far as the pinned code pins its own dependencies.
Scope the token and the permissions
Each workflow run gets a GITHUB_TOKEN. Since February 2023, new organisations and repositories default it to read-only. Older ones kept their read-write default unless someone changed it. Set permissions explicitly at the top of every workflow and raise them per job:
permissions:
contents: read
jobs:
comment:
permissions:
pull-requests: writeReplace stored cloud keys with OIDC
A long-lived cloud access key stored as a CI secret is exactly what the tj-actions and Trivy payloads went looking for. GitHub Actions can instead request a short-lived OpenID Connect token that your cloud provider exchanges for temporary credentials. It's scoped to a repository, branch or environment you choose:
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@<commit SHA> # v6
with:
role-to-assume: arn:aws:iam::<account-id>:role/deploy-from-main
aws-region: eu-west-1If a stolen token is only valid for minutes and only from your repository's main branch, most of its value is gone.
Don't build shell commands from untrusted input
Expressions like ${{ github.event.pull_request.title }} are substituted into the script before the shell runs it. A pull-request title that contains "; curl … | sh # becomes part of your command. GitHub's fix is to pass untrusted values through an environment variable, so the shell treats them as data:
# Vulnerable
- run: echo "Title: ${{ github.event.pull_request.title }}"
# Safer
- env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "Title: $TITLE"Be especially careful with pull_request_target. It runs with the base repository's secrets and permissions, so checking out and building a fork's code under it hands that code your secrets.
Know that log masking has limits
GitHub redacts registered secrets from logs, but its documentation says redaction isn't guaranteed. A secret that's transformed, such as base64-encoded, or stored as part of a JSON blob may print in the clear. CISA noted that the tj-actions payload double-encoded the secrets it dumped in base64. Masking looks for the registered value, not an encoding of it. Register derived values as separate secrets, and treat any secret that ever reached a public log as burned.
Dependencies are pipeline code too
Every npm ci or pip install in a pipeline runs other people's code, sometimes at install time. Past incidents include malicious versions of ua-parser-js, a widely used npm package, published in October 2021 (0.7.29, 0.8.0 and 1.0.0, per CISA). They also include dependency confusion, where a public package takes the name of a private internal one and a misconfigured resolver prefers the public copy. Controls that help:
- Lock files and `npm ci`, so builds install exactly what was reviewed
- Scoped or namespaced internal packages, with a registry setting that forces those scopes to your private registry
- A dependency review step on pull requests, which flags new packages and known-vulnerable versions before merge
A checklist
- Third-party actions pinned to full commit SHAs, updated by a bot
permissions:declared in every workflow, read-only by default- OIDC for cloud access, no long-lived cloud keys in CI secrets
- Untrusted event fields passed through
env:, never inlined intorun: - No
pull_request_targetworkflow that checks out fork code - Secret scanning on every push
- Build runners that are ephemeral: one job, then destroyed
- Production deploys only from a protected branch through a protected environment with required reviewers
Sources
- GitHub docs — Security hardening: secure use reference for GitHub Actions
- GitHub changelog — Default GITHUB_TOKEN permissions set to read-only (2 February 2023)
- CISA — Supply chain compromise of third-party tj-actions/changed-files (CVE-2025-30066) and reviewdog/action-setup (CVE-2025-30154)
- Aqua Security — Trivy ecosystem supply chain temporarily compromised (GHSA-69fq-xp46-6x23, CVE-2026-33634)
- Codecov — Bash Uploader security update
- CISA — Malware discovered in popular npm package, ua-parser-js (22 October 2021)