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>
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
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
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
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 -gwithout a version pin is an open door. Lock it to a specific version. - [ ] SHA-pin your action references. If you see
@v4or@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
${{ }}insiderun:blocks. Every one is a potential injection. Move them toenv: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 signaturesflags 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)