DEV Community

Cover image for Two Supply Chain Attacks in Two Weeks - Why Defense-in-Depth Saved Me
Felix Ortiz
Felix Ortiz

Posted on

Two Supply Chain Attacks in Two Weeks - Why Defense-in-Depth Saved Me

Two supply chain attacks hit my CI/CD pipeline in under two weeks. Neither caused damage. Here's why, and what I hardened afterward.

The trend no one can ignore

In late March 2026, the aquasecurity/trivy-action GitHub Action was compromised via tag poisoning. A mutable version tag was silently redirected to a malicious commit.

Less than two weeks later, a threat actor compromised an axios npm maintainer's account and published two backdoored versions (1.14.1 and 0.30.4) containing a hidden postinstall script that phoned home to a command-and-control server. Microsoft published a detailed technical analysis of the axios attack.

Two different attack vectors. Two different ecosystems. Same target: CI/CD pipelines.

This isn't a coincidence. Attackers are actively targeting build infrastructure because that's where the secrets live, where the deployments happen, and where a single compromised dependency can cascade into production. If your CI/CD pipeline isn't hardened against this class of attack, it's not a question of if, but when.

What happened

One of my scheduled CI workflows ran an unlocked global npm install (npm install -g without a pinned version) during the three-hour window the compromised axios versions were live on the registry. The runner pulled the malicious package and made contact with the attacker's C&C server for approximately six seconds.

The catch: axios wasn't in my package.json. It was a transitive dependency, pulled in by a tool I installed globally in the CI runner. My initial analysis checked every package.json and its transitive dependency tree across all projects. That came back clean. It took a deeper investigation of the CI workflows themselves to find the exposure: a global install that bypasses lockfiles entirely and never appears in any project manifest. That's what makes supply chain attacks so effective. The obvious places to look aren't where the problem lives.

The irony? It was my DAST (Dynamic Application Security Testing) scan. One of my security workflows got compromised.

Why it didn't matter

This is where defense-in-depth earned its keep.

The workflow followed least-privilege principles. The only credential present was a short-lived, read-scoped GITHUB_TOKEN with no access to cloud credentials, production secrets, deployment keys, or admin tokens. Per GitHub's documentation, "The GITHUB_TOKEN expires when a job finishes or after a maximum of 24 hours." The job completed about seven minutes after the incident window, and the token expired with it.

I also considered whether the token's contents: read scope could have enabled a pivot to my cloud environment. My deployment workflows authenticate to cloud providers via OIDC-based workload identity federation, not the GITHUB_TOKEN. Generating a cloud access token requires id-token: write permission, which the compromised workflow did not have. Even if the attacker read every workflow file in the repo and reverse-engineered my cloud auth setup, the GITHUB_TOKEN is the wrong credential entirely. It cannot be exchanged for a cloud access token.

The blast radius was limited by design: zero-trust posture, short-lived credentials, and the assumption that any component could be compromised at any time.

What I hardened

I treated this as an opportunity to harden the entire pipeline, not just patch the immediate vector.

Pinned the unlocked dependency

The root cause was a global npm install without a version pin. Unlike npm ci (which uses a lockfile), npm install -g <package> fetches whatever the registry serves at runtime.

# Before
run: npm install -g <package-name>

# After
run: npm install -g <package-name>@<trusted-version-number>
Enter fullscreen mode Exit fullscreen mode

SHA-pinned all GitHub Actions

Every action reference in my workflows used mutable version tags like @v4. These tags can be silently redirected to malicious commits, which is exactly what happened in the trivy-action attack. I replaced every tag with an immutable 40-character commit SHA:

# Before - mutable tag
- uses: actions/checkout@v4

# After - immutable SHA
- uses: actions/checkout@de0fac2e... # v6
Enter fullscreen mode Exit fullscreen mode

I also enabled the repository setting that requires all GitHub Actions to be referenced by SHA. This turns a convention into a gate: PRs that use mutable tags fail the policy check and can't merge.

Digest-pinned all container images

Container image tags (:latest, :stable, :17) are just as mutable as Git tags. I pinned every image reference to its immutable SHA256 digest:

# Before
image: postgres:17

# After
image: postgres@sha256:b994732f... # 17
Enter fullscreen mode Exit fullscreen mode

Fixed script injection patterns

My review surfaced places where GitHub Actions expressions (${{ }}) were interpolated directly inside shell scripts. This is a known script injection vector. I moved all such values into env: blocks:

# Before - injection risk
run: |
  if [ -n "${{ steps.some-step.outputs.VALUE }}" ]; then

# After - safe
env:
  STEP_VALUE: ${{ steps.some-step.outputs.VALUE }}
run: |
  if [ -n "$STEP_VALUE" ]; then
Enter fullscreen mode Exit fullscreen mode

Added automated update tooling

SHA-pinning and digest-pinning are only effective if the pins stay current. I added Dependabot to automatically propose PRs when new versions are available. This turns a one-time hardening effort into an ongoing practice.

Supply chain security checklist

If you take one thing from this post, go look at your CI/CD workflows right now.

  • [ ] Pin global installs. Any npm install -g without a version pin is an open door. Lock it to a specific version.
  • [ ] SHA-pin your action references. If you see @v4 or @v3, those are mutable tags. Replace them with immutable commit SHAs. Dependabot can keep them updated.
  • [ ] Digest-pin your container images. Same problem, same fix. Pin to @sha256: digests.
  • [ ] Fix script injection patterns. Search for ${{ }} inside run: blocks. Every one is a potential injection. Move them to env: blocks.
  • [ ] Kill unused secrets. List your repo secrets. If any aren't referenced in a workflows, delete them.
  • [ ] Enforce least-privilege permissions. Does every workflow need the permissions it has? Use permissions: blocks explicitly rather than relying on defaults.
  • [ ] Replace long-lived credentials with OIDC federation. If your CI/CD workflows authenticate to cloud providers using static secrets, switch to OIDC-based workload identity federation. Short-lived tokens scoped to a single job run are harder to steal and impossible to reuse.
  • [ ] Add behavioral analysis for dependencies. CVE databases don't catch zero-day supply chain attacks like the axios compromise. Tools that analyze package behavior at install time close that gap.
  • [ ] Verify lockfile integrity. Tampered lockfiles can redirect dependencies to rogue registries without changing package.json.
  • [ ] Check package provenance. npm audit signatures flags packages lacking OIDC attestations, which is a signal that the publish pipeline isn't verified.
  • [ ] Generate SBOMs. You need a bill of materials for compliance and incident response. When the next compromise drops, you want to answer "are we affected?" in minutes, not hours.

Top comments (0)