# 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

Source: https://socket.dev/glossary/ci-cd-security

Last updated: 2026-10-24

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](https://socket.dev/blog/tanstack-npm-packages-compromised-mini-shai-hulud-supply-chain-attack) 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](https://socket.dev/glossary/package-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](https://socket.dev/glossary/continuous-integration-ci) 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](https://socket.dev/glossary/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)](https://socket.dev/glossary/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. Both sides meet inside a single job. Nothing inside the job separates them.
>
> Trust map of a CI job. Untrusted inputs on the left: fork pull request code, outsider-controlled text such as pull request titles and issue bodies, third-party actions resolved by mutable tags, package dependencies and their install scripts, and restored cache entries. Privileged assets on the right: the GITHUB\_TOKEN with write scopes, repository and organization secrets, OIDC identity tokens, write access to the default branch cache, and deploy credentials. Both meet inside one job that shares one machine, one filesystem, and one process tree, with no isolation between steps.

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](https://socket.dev/glossary/supply-chain-attack) in the last two years. The [OWASP Top 10 CI/CD Security Risks](https://owasp.org/www-project-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](https://socket.dev/blog/nx-supply-chain-attack-investigation-github-actions-workflow-exploit) started with exactly that, in a `pull_request_target` workflow quoted in [Nx's advisory](https://github.com/nrwl/nx/security/advisories/GHSA-cxm3-wv7p-598c):

```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](https://socket.dev/blog/github-actions-supply-chain-attack-puts-thousands-of-projects-at-risk) 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](https://socket.dev/supply-chain-attacks/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](https://socket.dev/blog/mini-shai-hulud-actions) in September 2026 brought back two actions that GitHub had disabled during the [Mini Shai-Hulud](https://socket.dev/supply-chain-attacks/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](https://socket.dev/glossary/postinstall-scripts), `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](https://socket.dev/blog/ultralytics-pypi-package-compromised-through-github-actions-cache-poisoning) 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](https://socket.dev/blog/ghostaction-cloud-credentials) 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 risk | What it looks like in a real pipeline | Incidents |
| --- | --- | --- |
| CICD-SEC-4 Poisoned Pipeline Execution | Pull request code or text executes in a privileged workflow | Nx (Aug 2025), TanStack (May 2026) |
| CICD-SEC-3 Dependency Chain Abuse | The pipeline fetches actions or packages by a reference someone else controls | tj-actions/changed-files (Mar 2025), trivy-action (Mar 2026), actions-cool (Sep 2026) |
| CICD-SEC-9 Improper Artifact Integrity Validation | A poisoned cache is restored into a trusted build, and provenance still verifies | Ultralytics (Dec 2024), TanStack (May 2026) |
| CICD-SEC-5 Insufficient PBAC | A job holds more authority than its steps need | TanStack: id-token: write on the job that ran tests; Nx: a PR workflow could trigger publish.yml |
| CICD-SEC-6 Insufficient Credential Hygiene | Long-lived or unrotated credentials outlive the incident | Ultralytics 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 Management | One stolen human account can rewrite pipeline definitions | GhostAction (Oct 2026) |
| CICD-SEC-1 Insufficient Flow Control Mechanisms | A pipeline change runs without review | GhostAction 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](https://docs.github.com/en/actions/reference/security/secure-use) 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:

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

> Figure 2. The TanStack compromise, May 10–11, 2026 (UTC), from TanStack's postmortem. No npm token was stolen.
>
> Timeline of the May 2026 TanStack compromise in UTC, crossing three trust boundaries: fork code ran in a pull\_request\_target workflow, that run saved a poisoned cache into the main branch scope, and the release workflow restored it and leaked its own OIDC token, which published 84 malicious versions.

| Time (UTC) | Where | What happened |
| --- | --- | --- |
| May 10, 23:29 | Attacker's fork | Commit adds packages/history/vite\_setup.mjs, a roughly 30,000-line payload, marked \[skip ci\] |
| May 11, 10:49 | bundle-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:11 | bundle-size.yml | The job checks out refs/pull/7378/merge, runs pnpm install and the build, and the payload executes |
| 11:29 | Actions cache | A 1.1 GB pnpm-store entry is saved to the refs/heads/main scope under the key release.yml looks up |
| 11:31 | Attacker's fork | The PR is reset to a no-op, closed, and its branch deleted; the cache entry stays |
| 19:15 to 19:16 | release.yml (main) | Two release runs restore the poisoned cache in a job holding id-token: write |
| 19:20:39 | npm registry | The malware extracts the OIDC token from Runner.Worker memory and publishes 42 packages |
| 19:26:14 | npm registry | A second batch from the second run brings the total to 84 versions |
| 21:03 to 23:55 | Response | All 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](https://tanstack.com/blog/npm-supply-chain-compromise-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](https://socket.dev/blog/github-actions-checkout-blocks-pull-request-target-checkouts) 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](https://github.blog/changelog/2026-06-26-read-only-actions-cache-for-untrusted-triggers) 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](https://socket.dev/blog/npm-12) no longer resolves git dependencies unless allowed, and [pnpm 11](https://socket.dev/blog/pnpm-11-adds-new-supply-chain-protection-defaults) 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](https://socket.dev/glossary/least-privilege-principle) to every job, which then asks for what it needs. GitHub [made the `GITHUB_TOKEN` read-only by default](https://github.blog/changelog/2023-02-02-github-actions-updating-the-default-github_token-permissions-to-read-only/) 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](https://github.blog/changelog/2025-11-07-actions-pull_request_target-and-environment-branch-protections-changes/), `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](https://socket.dev/blog/introducing-github-actions-scanning-support), 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](https://socket.dev/blog/pnpm-10-0-0-blocks-lifecycle-scripts-by-default) 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](https://socket.dev/glossary/npm-trusted-publishing) swaps a long-lived npm token for an OIDC exchange bound to one workflow. After [npm's trusted publishing launch](https://socket.dev/blog/npm-trusted-publishing) 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](https://socket.dev/blog/github-actions-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?

| Reference | What runs | Survives a moved tag? | Pins actions it calls? | Cost |
| --- | --- | --- | --- | --- |
| Tag, e.g. @0.33.0 | Whatever the tag points to when the job starts | No | No | None up front; changes arrive silently |
| Immutable release | The release's commit, locked by the publisher | Only if locked before the compromise | No | Depends on the publisher opting in |
| Full commit SHA | One fixed commit | Yes | No, nested tag references still float | Updates need a bot or a person |
| Workflow lockfile (.github/workflows/actions.lock) | Locked commits for every action, with source repository IDs checked at run time | Yes | Yes, except reusable workflow calls | Public preview as of October 2026; one more file to review |

Our verdict: use a [workflow dependency lockfile](https://docs.github.com/en/actions/concepts/security/workflow-dependency-lockfiles) 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](https://github.blog/changelog/2025-08-15-github-actions-policy-now-supports-blocking-and-sha-pinning-actions/) 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.
