Security guide

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.1

Dependabot 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: write

Replace 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-1

If 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 into run:
  • No pull_request_target workflow 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

Start with a $500 audit.

See exactly where you stand. Actionable findings in a week.

The full report and debrief call. Delivery in 5–7 business days.