Back to glossary

CI/CD Security

CI/CD security protects build and release pipelines from running attacker code with the pipeline's own tokens, secrets, and publishing rights.

Also known as CI/CD pipeline security, pipeline security, CI security, build pipeline security, continuous integration security, DevOps pipeline security, CI/CD security risks, software delivery pipeline security

CI/CD security, also called CI/CD pipeline security, is the practice of keeping continuous integration and delivery pipelines from executing attacker-controlled code with the pipeline's own credentials. A pipeline runs code from pull requests, dependencies, third-party actions, and caches on the same machine that holds publish tokens and cloud keys, so one untrusted input can become a signed, trusted release.

The pipeline compromises we have tracked since 2024 all make the same move: get attacker code into a job that holds something valuable. In May 2026, the TanStack compromise published 84 malicious versions across 42 npm packages without stealing a single npm token. Code running inside TanStack's own release workflow minted a fresh publishing credential from the runner's memory and used it. Our position is that a pipeline is a production system that executes untrusted code by design. The job, not the repository or the workflow, is the real trust boundary. Keep untrusted code out of jobs that hold credentials, and know what every privileged job will execute before it runs. Short-lived credentials and provenance are worth adopting, but neither stops code that is already running inside the trusted job.

What is CI/CD security?

#

CI/CD security is the discipline of protecting the systems that turn a commit into a release: the source host, the CI service, its runners, and the credentials those runners hold. Continuous integration builds and tests every change, and continuous delivery packages and ships it. Both are automation acting on a team's behalf, so both need standing authority to push, publish, and deploy.

To see where the risk comes from, follow one GitHub Actions job from event to exit. GitLab CI, Jenkins, and CircleCI differ in syntax, not in shape, and GitHub Actions security covers that platform's controls in more depth.

  1. An event fires: a push, a pull request, an issue comment, a schedule. The event decides which workflow definition runs and with what privileges.
  2. The platform provisions a runner: a fresh VM on GitHub-hosted runners, or a machine you operate on self-hosted ones.
  3. At job setup, the runner resolves every uses: reference to a commit and downloads that action's code. A tag such as @v4 resolves at this moment, not when someone wrote the workflow.
  4. The platform mints a GITHUB_TOKEN scoped to the repository and injects the secrets the workflow references. A job granted id-token: write can also request an OpenID Connect (OIDC) token that registries and cloud providers accept as identity.
  5. Steps run as ordinary processes: shell scripts, npm ci, test suites, actions. Caches are restored by key at the start and saved at the end.

Every step in a job shares one machine, one filesystem, and one process tree. The step that installs dependencies has the same reach as the step that publishes. The platform cannot tell a build script from a malicious install hook, because both are just processes. That design is deliberate, and it is what makes pipelines composable and fast. It also means everything that runs in a CI job is as trusted as its most powerful credential.

Figure 1 shows the two sides that meet on a runner. The untrusted side holds code from fork pull requests, text an outsider controls (pull request titles, branch names, issue bodies), third-party actions resolved through mutable tags, package dependencies and their install scripts, and cache entries restored by key. The privileged side holds the GITHUB_TOKEN and its write scopes, repository and organization secrets, OIDC tokens minted under id-token: write, write access to the default branch's cache, and cloud and deploy credentials. In the example job, checkout, npm ci, npm test, and npm publish all run in that same space. Nothing inside the job separates the two sides.

How do attackers get code into a CI/CD pipeline?

#

Attackers get code into a CI/CD pipeline through four bridges between untrusted input and a privileged job. Each bridge has carried a real supply chain attack in the last two years. The OWASP Top 10 CI/CD Security Risks describes the same failure modes in the abstract.

Untrusted events that run untrusted code

#

GitHub's pull_request trigger runs fork code with a read-only token and no secrets. pull_request_target exists for the opposite job: labeling or commenting on a fork's pull request, which needs write access. It runs the base repository's workflow with the base repository's token, secrets, and cache scope. That design is safe only while the workflow never executes anything from the pull request. OWASP calls the failure Poisoned Pipeline Execution.

The subtler variant needs no checkout at all. GitHub expands ${{ }} expressions into the script text before the shell ever parses it. The Nx compromise started with exactly that, in a pull_request_target workflow quoted in Nx's advisory:

YAML
- name: Validate PR title
  run: |
    echo "Validating PR title: ${{ github.event.pull_request.title }}"

We reproduced the substitution locally. The first script is what the runner generates from that step; the second passes the same title through an environment variable instead:

Bash
$ TITLE='fix: typo $(echo pwned-as-$(whoami))'
$ printf 'echo "Validating PR title: %s"\n' "$TITLE" > vuln.sh && bash vuln.sh
Validating PR title: fix: typo pwned-as-root
$ printf 'echo "Validating PR title: $PR_TITLE"\n' > safe.sh && PR_TITLE="$TITLE" bash safe.sh
Validating PR title: fix: typo $(echo pwned-as-$(whoami))

The pull request title became shell code. Nx's attacker opened a pull request against an outdated branch that still carried the vulnerable workflow, used the injection to trigger the more privileged publish.yml, and sent its npm token to a webhook. Malicious Nx versions reached npm on August 26, 2025.

The pipeline's own dependencies

#

A workflow's uses: lines are dependencies, and most point at tags their owners can move. On March 14 and 15, 2025, the tj-actions/changed-files compromise repointed tags v1 through v45.0.7 at a commit that dumped CI secrets into workflow logs (CVE-2025-30066). More than 23,000 repositories used that action. In March 2026, the Trivy GitHub Actions compromise force-pushed 75 of 76 version tags of aquasecurity/trivy-action, which more than 10,000 workflow files referenced. Each forged commit copied the original's author, timestamps, and message, so git log looked normal.

Tag mutability is easy to see. This is the resolution a runner performs at job setup, as of October 24, 2026:

Bash
$ git ls-remote https://github.com/actions/checkout 'refs/tags/v7*'
3d3c42e5aac5ba805825da76410c181273ba90b1	refs/tags/v7
9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0	refs/tags/v7.0.0
3d3c42e5aac5ba805825da76410c181273ba90b1	refs/tags/v7.0.1

v7 and v7.0.1 are the same commit today. Anyone with push access can make v7 point somewhere else tomorrow. A bad tag can even outlive its cleanup. The actions-cool re-enablement in September 2026 brought back two actions that GitHub had disabled during the Mini Shai-Hulud campaign, with their malicious tags intact. About 15,000 repositories depend on actions-cool/issues-helper alone, and any that referenced it by tag ran the payload again on its next workflow run, with no new attacker action.

Package dependencies run in the same job. When compromised TanStack versions landed in other projects, the payload arrived through an optionalDependencies entry that pointed at a git commit:

JSON
"optionalDependencies": {
  "@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c"
}

That commit's package.json declared a prepare lifecycle script, bun run tanstack_runner.js && exit 1. npm runs prepare for git dependencies during install, so a CI job that installed an affected version with scripts enabled executed a credential stealer before any test ran.

Shared state across trust levels

#

Caches, artifacts, and workspaces outlive the job that wrote them. Until June 2026, a pull_request_target run could save cache entries into the default branch's scope, where trusted push workflows restore them. The Ultralytics compromise used that path to ship a cryptominer in versions 8.3.41 and 8.3.42 in December 2024, and TanStack's attacker used it to reach the release job. Job permissions do not help here. As TanStack's postmortem puts it, "Setting permissions: contents: read does not block cache mutation."

Stolen credentials that write workflows

#

The last bridge skips the pipeline's logic entirely. An attacker with a maintainer's credential commits a new workflow and lets the platform run it. On October 8, 2026, the GhostAction campaign committed .github/workflows/security-audit.yml to 346 repositories from two compromised accounts, including Uber's athenadriver. The workflow runs on every push and posts the repository's Actions secrets, plus credentials found anywhere in its git history, to a hardcoded IP address. By October 9, we had counted more than 500 accounts committing the same workflow.

These incidents map cleanly onto OWASP's taxonomy:

OWASP riskWhat it looks like in a real pipelineIncidents
CICD-SEC-4 Poisoned Pipeline ExecutionPull request code or text executes in a privileged workflowNx (Aug 2025), TanStack (May 2026)
CICD-SEC-3 Dependency Chain AbuseThe pipeline fetches actions or packages by a reference someone else controlstj-actions/changed-files (Mar 2025), trivy-action (Mar 2026), actions-cool (Sep 2026)
CICD-SEC-9 Improper Artifact Integrity ValidationA poisoned cache is restored into a trusted build, and provenance still verifiesUltralytics (Dec 2024), TanStack (May 2026)
CICD-SEC-5 Insufficient PBACA job holds more authority than its steps needTanStack: id-token: write on the job that ran tests; Nx: a PR workflow could trigger publish.yml
CICD-SEC-6 Insufficient Credential HygieneLong-lived or unrotated credentials outlive the incidentUltralytics 8.3.45 and 8.3.46, published with a PyPI token left active after the first breach; Trivy's second breach, after a non-atomic rotation
CICD-SEC-2 Inadequate Identity and Access ManagementOne stolen human account can rewrite pipeline definitionsGhostAction (Oct 2026)
CICD-SEC-1 Insufficient Flow Control MechanismsA pipeline change runs without reviewGhostAction workflows committed straight to default branches and run on push

How do attackers steal secrets from a CI/CD pipeline?

#

Attackers steal secrets from a CI/CD pipeline by reading them off the runner, because a job's secrets are plaintext to every process in that job. Log masking is redaction, not access control, and GitHub's own guidance warns that redaction is not guaranteed once a value is transformed.

Secrets reach a job only when the workflow references them, and the runner keeps them in memory to do the substitution. The Trivy payload first read /proc/<pid>/environ for the runner processes. On GitHub-hosted Linux runners, where sudo needs no password, it then dumped the memory of the Runner.Worker process and searched it for the runner's internal secret format:

Plain text
"<name>":{"value":"<secret>","isSecret":true}

The same technique recovers OIDC tokens. TanStack's release job had id-token: write, so the runner could mint a token that npm accepts as proof of the workflow's identity. The malware found Runner.Worker through /proc/*/cmdline, read its memory through /proc/<pid>/maps and /proc/<pid>/mem, extracted the token, and posted packages straight to registry.npmjs.org. The workflow's own Publish step was skipped because tests failed. The malicious publish succeeded anyway.

Three consequences follow from this. Secret scoping is per job, not per step, so a secret referenced anywhere in a job is exposed to every dependency that job runs. The GITHUB_TOKEN is a credential too: Nx's attacker used a pull request workflow's token to start a more privileged workflow. Exfiltration rarely needs an exotic channel: when its primary endpoint failed and the workflow had passed it a personal access token, the Trivy payload created a public tpcp-docs repository on the victim's own GitHub account and uploaded the stolen bundle as a release asset.

Anatomy of the TanStack compromise

#

The TanStack compromise is the clearest recent example because it crossed three trust boundaries, each of which looked safe on its own.

  1. 1
    May 10 23:29Attacker fork

    Commit adds packages/history/vite_setup.mjs, a ~30,000-line payload, tagged [skip ci].

  2. 2
    May 11 10:49bundle-size.yml · pull_request_target

    PR #7378 opens. The workflow runs at once; the first-time-contributor approval gate does not apply to this trigger.

  3. !
    Boundary 1: fork code runs with the base repository's context.
  4. 3
    11:11bundle-size.yml

    Job checks out refs/pull/7378/merge, runs pnpm install and the build. The payload executes.

  5. 4
    11:29Actions cache

    A 1.1 GB pnpm store is saved to the refs/heads/main scope under the key release.yml will look up.

  6. !
    Boundary 2: a PR-triggered run writes the trusted branch's cache.
  7. 5
    11:31Attacker fork

    PR reset to a no-op, closed, branch deleted. The cache entry stays.

  8. 6
    19:15–19:16release.yml · push to main

    Two release runs restore the poisoned cache. The job holds id-token: write.

  9. !
    Boundary 3: code inside the job takes the job's identity.
  10. 7
    19:20:39npm registry

    Malware reads the OIDC token from Runner.Worker memory and publishes 42 packages. The workflow's own Publish step never ran.

  11. 8
    19:26:14npm registry

    Second batch from the second run: 84 versions in total.

  12. 9
    21:03–23:55Response

    All 84 versions deprecated by 21:03; npm finishes removing tarballs at 23:55.

Figure 2. The TanStack compromise, May 10–11, 2026 (UTC), from TanStack's postmortem. No npm token was stolen.
Time (UTC)WhereWhat happened
May 10, 23:29Attacker's forkCommit adds packages/history/vite_setup.mjs, a roughly 30,000-line payload, marked [skip ci]
May 11, 10:49bundle-size.yml (pull_request_target)PR #7378 opens and the workflow runs immediately; the first-time-contributor approval gate does not apply to this trigger
11:11bundle-size.ymlThe job checks out refs/pull/7378/merge, runs pnpm install and the build, and the payload executes
11:29Actions cacheA 1.1 GB pnpm-store entry is saved to the refs/heads/main scope under the key release.yml looks up
11:31Attacker's forkThe PR is reset to a no-op, closed, and its branch deleted; the cache entry stays
19:15 to 19:16release.yml (main)Two release runs restore the poisoned cache in a job holding id-token: write
19:20:39npm registryThe malware extracts the OIDC token from Runner.Worker memory and publishes 42 packages
19:26:14npm registryA second batch from the second run brings the total to 84 versions
21:03 to 23:55ResponseAll 84 versions are deprecated by 21:03, and npm finishes removing tarballs at 23:55

The table above carries the same events as Figure 2, from TanStack's postmortem. Boundary one fell when fork code ran in a pull_request_target job. Boundary two fell when that job wrote the cache the main branch trusts. Boundary three fell when code inside the release job read the job's own OIDC token. "The chain only works because each vulnerability bridges the trust boundary the others assumed," TanStack's maintainers wrote.

GitHub has since closed the first two links by default. The actions/checkout v7 change refuses to check out fork pull request refs inside pull_request_target, for workflows on a current checkout version. Untrusted triggers also get read-only cache tokens as of June 26, 2026. The third link is still open. Any job that holds id-token: write and runs third-party code will publish for an attacker the moment that code turns malicious.

Two package manager defaults also landed on the propagation path. npm v12 no longer resolves git dependencies unless allowed, and pnpm 11 blocks git and tarball subdependencies by default. pnpm 11 also waits 24 hours before resolving a newly published version. npm finished removing the malicious TanStack tarballs about four and a half hours after the first publish, so a CI job behind that cooldown would never have installed them.

How do you implement security in a CI/CD pipeline?

#

You implement CI/CD security by making each job a deliberate trust boundary. Jobs that run untrusted code get no secrets, no write token, no cache writes, and no OIDC. Jobs that hold credentials run as little unreviewed code as possible. Here is what that looks like for an npm package, using action versions current as of October 2026:

YAML
name: ci
on:
  pull_request:            # never pull_request_target for builds
  push:
    branches: [main]

permissions: {}            # default-deny for every job
cache-mode: read           # no job saves to the cache unless granted

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
      - run: npm ci --ignore-scripts
      - run: npm test

  publish:
    if: github.event_name == 'push'
    needs: test
    runs-on: ubuntu-latest
    environment: npm-publish # required reviewers, main branch only
    cache-mode: none
    permissions:
      contents: read
      id-token: write        # the only job that can mint OIDC
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: 24
          registry-url: https://registry.npmjs.org
      - run: npm ci --ignore-scripts
      - run: npm publish

The test suite, the code most likely to pull in something new, runs in a job with nothing worth stealing. The publish job does not run tests and cannot touch the cache. It still installs dependencies, and any build step that runs there executes third-party code next to the OIDC grant. Moving the build out means passing an artifact between jobs, which is shared state of its own. Each control in that file has a cost and a gap like this one.

Default-deny permissions. permissions: {} at the top applies least privilege to every job, which then asks for what it needs. GitHub made the GITHUB_TOKEN read-only by default on February 2, 2023, but only for newly created enterprises, organizations, and personal repositories. Existing ones kept read/write, and new repositories inherit their organization's setting, so many still issue write tokens. Permissions do not govern cache writes or what a process can read from memory.

Keep pull request code out of privileged events. Use pull_request for anything that builds or tests contributor code. Since December 8, 2025, pull_request_target always runs the default branch's workflow, which closes the stale-branch path Nx's attacker used. Checkout v7 refuses fork checkouts in that context, and GitHub scheduled the backport to supported major versions for July 16, 2026. Neither covers a git fetch, a gh pr checkout, or an issue_comment workflow.

Treat outsider text as data. Pass titles, branch names, and comment bodies through env: and quote them in the script. That fixes shell injection only. An action that feeds the same value to eval or a template is still exposed.

Pin what the job executes, then inspect it. Pinning decides which code runs. It does not decide whether that code is safe, which is the subject of the table below. Socket's GitHub Actions scanning, launched in October 2025 for Business and Enterprise plans, resolves each tag to the commit it points to at scan time, statically analyzes the bundled JavaScript, and traces untrusted inputs to shell and environment sinks with the Argus taint engine. A tag moved after a scan is flagged on the next scan, not at the moment the runner resolves it.

Turn install-time execution off in CI. npm v12 skips dependency install scripts by default, and its opt-in strict-allow-scripts setting turns a skipped script into a hard failure, which is what CI wants. pnpm 10 made the same change in January 2025. --ignore-scripts does the same on older npm versions. Wrapping installs in Socket Firewall (sfw npm ci) adds a network-level check that blocks confirmed malware before the tarball reaches the runner. Socket Firewall Free blocks only human-confirmed malware and warns on packages flagged by AI alone. None of this stops code that runs at import or inside a test, and TanStack's payload arrived through a poisoned build cache, not an install script.

Replace stored tokens with short-lived identity, then fence it. Trusted publishing swaps a long-lived npm token for an OIDC exchange bound to one workflow. After npm's trusted publishing launch on July 31, 2025, Nx moved every package to it within 24 hours of its compromise. Put id-token: write on the publish job alone, behind an environment with required reviewers. TanStack shows the limit: OIDC removes the token an attacker could steal, not the job an attacker could hijack. Reviewers approve a run, not the bytes it restores.

Isolate caches. Read-only cache tokens for untrusted triggers are now the default. cache-mode, added in September 2026, lets a job declare read, write, write-only, or none. Use none on any job that holds a credential. Artifacts passed between jobs and workspaces left on persistent runners remain shared state.

Use ephemeral runners and limit egress. GitHub's guidance is that self-hosted runners "should almost never be used for public repositories," because any pull request can reach them. An egress allowlist would have stopped the Trivy payload's primary upload to a typosquatted domain. It would not have stopped the fallback upload to github.com.

Verify provenance downstream, but do not stop there. Provenance ties an artifact to a repository, a workflow, and a commit. Ultralytics' poisoned releases carried valid attestations, and the TanStack payload carries code to submit Sigstore attestations for the packages it republishes.

How should a workflow reference a third-party action?

#
ReferenceWhat runsSurvives a moved tag?Pins actions it calls?Cost
Tag, e.g. @0.33.0Whatever the tag points to when the job startsNoNoNone up front; changes arrive silently
Immutable releaseThe release's commit, locked by the publisherOnly if locked before the compromiseNoDepends on the publisher opting in
Full commit SHAOne fixed commitYesNo, nested tag references still floatUpdates need a bot or a person
Workflow lockfile (.github/workflows/actions.lock)Locked commits for every action, with source repository IDs checked at run timeYesYes, except reusable workflow callsPublic preview as of October 2026; one more file to review

Our verdict: use a workflow dependency lockfile where your repository can adopt it, a full commit SHA everywhere else, and never a tag for an action that can see a secret. Since August 2025, organizations can enforce SHA pinning through the allowed actions policy. Immutable releases are not enough: GitHub's releases page showed an Immutable badge next to the poisoned trivy-action tags. And every row in the table answers "which code," never "is it safe." A SHA pinned to a malicious commit is reproducibly malicious.

Why platform defaults have not closed the problem

#

Between August 2025 and October 2026, GitHub and npm shipped these pipeline defaults and controls:

  • August 15, 2025: allowed actions policies can require full-SHA pins and block specific actions.
  • December 8, 2025: pull_request_target always runs the default branch's workflow file.
  • June 2026: actions/checkout v7 refuses fork checkouts in pull_request_target, with a backport to supported major versions scheduled for July 16.
  • June 26, 2026: untrusted triggers get read-only cache tokens.
  • July 2026: npm v12 turns off dependency install scripts, git dependencies, and remote tarball dependencies by default.
  • September 10, 2026: cache-mode gives per-job cache control.
  • Public preview: workflow dependency lockfiles in .github/workflows/actions.lock.

Each fix is scoped to one mechanism. Checkout v7 does not cover other ways of fetching code. Read-only cache tokens do not cover artifacts. Trusted publishing does not cover code inside the publishing job. The class of bug persists: a privileged job that executes code nobody reviewed. Platforms can make the most common shape of that bug harder to write. They cannot tell you what is in the code your job runs.

What provenance cannot prove

#

Provenance answers where an artifact was built. It cannot answer what ran while it was built. A signed statement that a package came from your release workflow is exactly as trustworthy as every action, dependency, and cache entry that workflow executed. In the Ultralytics case, the build cache was poisoned and every attestation still verified. The control most pipelines still lack is an inventory of what each privileged job will execute, checked before that job receives a credential. Platforms now lock references and attest outputs. Inspecting the inputs is still left to you.