POISONED CI ACTIONS AND DEVOPS LEAKAGE

Your CI/CD has more privilege than your production server. It pushes code, signs builds, deploys to prod, and holds the keys for every cloud service you use. AI generators help you set it up. They don't always set it up safely.

The scenario referenced below runs on gapbench.vibe-eval.com — a public security benchmark we operate.

Where the value is

CI/CD has access to everything important. Source code, build artifacts, signing keys, deploy credentials, environment variables for production. If an attacker gets execution inside your CI, they can do everything you can do — push code, deploy, mint new credentials, exfiltrate everything in one pipeline run.

The supply-chain attacks of the last few years — SolarWinds, the npm event-stream incident, the various GitHub Action compromises — all go through this surface. AI-generated CI doesn’t introduce new attack types but it makes the existing ones easier to ship by default.

Poisoned CI actions

- uses: actions/checkout@v4
- uses: third-party/some-helper@latest
- uses: another/action@main

@v4, @latest, @main are mutable refs. The action’s owner can repoint them. If their repository gets compromised — keys leaked, account taken over — and the attacker pushes a malicious commit to the same tag or branch, your next CI run runs the attacker’s code with all your CI’s secrets.

This has happened. Most famously the tj-actions/changed-files compromise in 2025, where the attacker rewrote the v45 tag to point at a malicious commit that exfiltrated secrets via the GitHub Actions runner. Workflows pinning by tag were affected; workflows pinning by SHA were not.

The fix:

- uses: actions/checkout@a1b2c3d4e5f6...   # full commit SHA

GitHub Actions has a Dependabot equivalent that proposes SHA bumps when actions update. The right discipline: SHA-pin every third-party action, let Dependabot propose updates, review and merge. Actions from actions/* (the official org) are generally safer to tag-pin, though the same logic applies in principle.

Live: https://gapbench.vibe-eval.com/site/poisoned-ci-action/.

Terraform state leak

Terraform state files describe your entire infrastructure. They contain database passwords, API keys, IAM credentials for everything Terraform ever provisioned. They’re a single-file leak that, if found, opens every door.

Common ways state ends up public:

  1. Public S3 bucket. The state backend is S3, the bucket policy is permissive, attacker reads it.
  2. Committed to Git. Developer ran terraform apply locally, didn’t add terraform.tfstate to .gitignore, the file gets committed. Git history retains it forever, even after deletion.
  3. In a public CI artifact. The CI runs Terraform, the state file ends up in artifact storage, the artifact storage is configured for public download.

Fix: use a remote state backend (S3 with private bucket, Terraform Cloud, GCS with private bucket). Encrypt state at rest and in transit. Never let state touch a developer’s local disk for production environments. Audit .gitignore to include terraform.tfstate*. Check Git history for accidentally-committed state with git log --all -- terraform.tfstate.

Live: https://gapbench.vibe-eval.com/site/terraform-state-leak/.

Docker registry credentials

- name: Docker login
  run: docker login -u ${{ secrets.DOCKER_USER }} -p ${{ secrets.DOCKER_PASS }} my-registry.com

docker login -p is deprecated specifically because it leaks the password to logs and process listings. The replacement, docker login --password-stdin, doesn’t. AI-generated workflow configs frequently use -p because that’s the shorter pattern in older tutorials.

The result: if your repo’s CI logs are public (open-source projects, accidentally public), the password is visible. Even if the logs aren’t public, the password ends up in the runner’s process list during the run, and any other process on the runner — say, in a third-party action that ran earlier in the workflow — could read it.

Fix: use --password-stdin. Better, use a token-exchange flow (GitHub Actions has permissions: id-token: write and OIDC integration with major registries) that doesn’t require a long-lived credential at all.

Live: https://gapbench.vibe-eval.com/site/docker-config-leak/.

npm typosquat

We see this less often than the other three but it’s a real failure mode. The AI generator suggests npm install for a package whose name is one character off from the real one. The user accepts the suggestion. The malicious package’s postinstall script runs and exfiltrates whatever it can reach — env vars, SSH keys, AWS credentials.

Mitigations: pin exact versions. Use npm ci with a lockfile, not npm install. Use a private npm registry with allow-listed packages for production. For an AI-codegen workflow specifically, audit the AI’s npm install suggestions before accepting them.

Live: https://gapbench.vibe-eval.com/site/npm-typosquat/.

A specific incident — Terraform state in a public S3 bucket

Anonymized. An infra team stored Terraform state in S3, with bucket policy “private.” Or so they thought. The bucket was actually configured with BlockPublicAcls: false and a permissive bucket policy granting s3:GetObject to arn:aws:iam::*:user/* — which an engineer had set up months earlier when they were debugging a permissions issue and never tightened.

Anyone with any AWS account could aws s3 cp s3://team-tf-state/prod.tfstate - and read the state. Standard Shodan-style enumeration of S3 buckets via certificate transparency and DNS led an attacker to the bucket. They downloaded the state.

The state contained:

  • RDS master password (because the team had used Terraform’s random_password resource and stored the result in state, instead of pulling from Secrets Manager).
  • IAM access keys for resources Terraform created.
  • Internal hostnames and IP ranges.
  • A list of every customer subdomain provisioned dynamically.

The attacker used the RDS password to connect — but the database wasn’t reachable from the internet (small mercy). They did use the IAM keys to create new IAM users, and those gave them broader cloud access. Detection happened ~3 days in when the team’s CloudTrail alerting fired on unusual IAM activity.

Cleanup: rotate everything in state, migrate to Terraform Cloud (which encrypts state and gates access via SSO), audit for any other public bucket. The biggest infrastructural change was moving secrets out of Terraform state entirely — into Secrets Manager, retrieved via data sources, never written to state.

The lesson: Terraform state is a credentials file. Treat it like one. Never let it touch a public bucket, never commit it, and prefer remote state in a system that gates access via your identity provider.

The supply-chain layers

CI/CD’s supply chain has layers. Compromise at any layer compromises everything below:

  1. Source control (GitHub, GitLab, Bitbucket). Compromised account → can push code, change CI config.
  2. CI runner platform (GitHub Actions, GitLab CI, CircleCI). Compromised platform → arbitrary code in your CI.
  3. CI configuration (.github/workflows/*.yml). Modified config → arbitrary code in your CI for legitimate users.
  4. Third-party actions (uses: in GitHub Actions). Compromised action → arbitrary code in your CI.
  5. Build dependencies (npm, pypi packages). Typosquat or compromised package → arbitrary code in build.
  6. Build tools (compilers, bundlers). Compromised tool → backdoor in build output.
  7. Container base images (FROM in Dockerfile). Compromised base → backdoor in runtime.
  8. Registry (Docker Hub, ECR, GHCR). Compromised registry → can push malicious images.
  9. Deploy infrastructure (Kubernetes, Vercel, etc). Compromised platform → arbitrary code in production.

Most teams secure layers 1, 2, 5, 8 with reasonable defaults. The middle layers (3, 4, 6, 7) are where AI-generated CI configs introduce risk.

Wrong fix vs right fix — GitHub Actions

# WRONG: tag-pinned, mutable
- uses: actions/checkout@v4
- uses: third-party/some-helper@latest
- uses: another/action@main
# WRONG: SHA-pinned but no Dependabot, will go stale
- uses: actions/checkout@a1b2c3d4...
# No automation to keep this updated; will be vulnerable to old CVEs
# RIGHT: SHA-pinned + Dependabot configured
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
# .github/dependabot.yml ensures Dependabot proposes SHA bumps weekly
# Reviewer approves bumps after checking the diff

Cross-stack notes

The supply-chain failure mode plays out across CI platforms. Each has its own conventions:

  • GitHub Actions: SHA-pinning is the standard. Action signing is being rolled out gradually.
  • GitLab CI: No equivalent of “third-party actions” by default — most pipelines use shell scripts or Docker images, which have the same trust issues but at the image layer.
  • CircleCI: Orbs are the equivalent of actions. Same trust shape.
  • Jenkins: Plugins are an even bigger surface. Plugin compromise has been the source of major CI compromises historically.

The general advice: pin dependencies at the SHA level wherever possible, automate the bump process, audit the diff on every bump.

How we detect

CI actions: we read .github/workflows/*.yml from the repo (when we have repo access via the customer) or from public GitHub for OSS, parse the uses: lines, flag any unpinned (tag-only or branch-only) reference, and produce a report. Plus: we check whether each referenced action’s commit SHA has changed since the last detected pin, which catches drift.

Terraform state: the public-bucket variant is found by the same S3 sweep we use for public buckets. The accidentally-committed variant is found by scanning Git history for *.tfstate* patterns.

Docker creds: we look for docker login -p patterns in workflow files. We also check whether any actions referenced in the workflow are known to log credentials (some early versions of certain actions did).

npm typosquat: the harder one. We compare installed packages against a list of known typosquats (maintained by npm and several security vendors). For dynamically-installed packages — npm install from CI commands — we hook into the install and verify against the typosquat list.

CWE / OWASP

  • CWE-829 — Inclusion of Functionality from Untrusted Control Sphere
  • CWE-1357 — Reliance on Insufficiently Trustworthy Component
  • CWE-200 — Information Exposure (state, creds)
  • OWASP Top 10 — A06:2021 Vulnerable and Outdated Components, A08:2021 Software and Data Integrity Failures

Reproduce it yourself

COMMON QUESTIONS

01
What is a poisoned CI action?
GitHub Actions lets you reference third-party actions by tag (uses: actions/checkout@v4) or by commit SHA (uses: actions/checkout@a1b2c3...). Tags are mutable — the action's owner can repoint v4 at a new commit. If they do that, every workflow using @v4 picks up the new code on the next run. If the action's repo gets compromised, every CI run on every project using @v4 runs the attacker's code. SHA-pinning is the fix.
Q&A
02
What is the Terraform state leak?
Terraform state files contain secrets — RDS passwords, API keys, IAM access keys for resources Terraform created. If your team stores state in S3 with permissive bucket policy, on a developer's laptop that gets backed up to a public Dropbox, or in a public Git repo by accident, an attacker reads every secret in your infrastructure. We find Terraform state in public buckets and public Git history regularly.
Q&A
03
What is Docker registry credential leakage?
Your CI builds a Docker image and pushes to a private registry. The push needs a credential. AI-generated CI configs sometimes hardcode the credential, log it inadvertently (docker login output gets dumped to logs), or store it in a way that's readable by other workflows in the same repo. Once an attacker has the credential, they can pull your private images — which often contain secrets baked into them.
Q&A
04
What is an npm typosquat?
A typosquat is a malicious package whose name is a typo of a popular legitimate package — 'lodahs' for 'lodash', 'react-aut' for 'react-auth'. The malicious package contains real functionality (so the import works) plus a payload that runs at install. AI generators occasionally suggest typosquatted package names, especially for less common packages where the model has seen variations and picks the wrong one.
Q&A
05
Where can I see this on a real URL?
https://gapbench.vibe-eval.com/site/poisoned-ci-action/ has the mutable-tag pattern. https://gapbench.vibe-eval.com/site/devops-leak/ has the backup and CI leakage. https://gapbench.vibe-eval.com/site/terraform-state-leak/ has the public-state-bucket variant. https://gapbench.vibe-eval.com/site/docker-config-leak/ has the registry-cred exposure. https://gapbench.vibe-eval.com/site/npm-typosquat/ has the typosquat install.
Q&A
06
What CWE does this map to?
CWE-829 (Inclusion of Functionality from Untrusted Control Sphere), CWE-1357 (Reliance on Insufficiently Trustworthy Component), CWE-200 (Information Exposure for state and creds), CWE-829 again for npm typosquat. OWASP A06:2021 (Vulnerable and Outdated Components), A08:2021 (Software and Data Integrity Failures).
Q&A

AUDIT YOUR CI/CD AND IAC

We probe for unsafe action references, leaked Terraform state, exposed Docker registry creds, and npm typosquat installs.

RUN THE SCAN