Supply Chain Security Testing: A Complete Guide for DevSecOps Teams

Supply Chain Security Testing: A Complete Guide for DevSecOps Teams

Software supply chain attacks have become one of the most effective attack vectors in modern software. SolarWinds compromised 18,000 organizations through a single tampered build. XZ Utils nearly backdoored OpenSSH in half the Linux servers on the internet. The pattern is consistent: attackers don't attack your software — they attack the infrastructure that builds it.

This guide covers the full spectrum of supply chain security testing, from individual tool usage to building a comprehensive defense-in-depth strategy.

Understanding the Attack Surface

A software supply chain attack can occur at any point between "developer writes code" and "code runs in production":

Source code → Build system → Dependencies → Artifacts → Registry → Deployment → Runtime
    ↑              ↑              ↑             ↑            ↑           ↑           ↑
  repo           CI/CD          npm/pip       container    Docker      k8s        process
  compromise     compromise     compromise    tampering    Hub abuse   miscfg     injection

Each arrow represents a potential attack surface. Supply chain security testing systematically addresses each one.

Layer 1: Source Code Integrity

Signed Commits

Start at the source: require signed commits to prove code came from legitimate contributors.

# Configure git to sign commits
git config --global commit.gpgsign <span class="hljs-literal">true
git config --global user.signingkey YOUR_GPG_KEY_ID

<span class="hljs-comment"># Or use SSH signing (simpler)
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub

<span class="hljs-comment"># Verify a commit
git verify-commit HEAD

Branch Protection Rules

On GitHub:

# Repository settings
required_pull_request_reviews:
  required_approving_review_count: 1
  dismiss_stale_reviews: true
  require_code_owner_reviews: true
required_status_checks:
  strict: true
  contexts:
    - security-scan
    - dependency-review
require_signed_commits: true
enforce_admins: true

Dependency Review

GitHub's dependency review action blocks PRs that introduce vulnerable dependencies:

# .github/workflows/dependency-review.yaml
name: Dependency Review

on:
  pull_request:

permissions:
  contents: read
  pull-requests: write

jobs:
  dependency-review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Review dependencies
        uses: actions/dependency-review-action@v4
        with:
          fail-on-severity: high
          deny-licenses: GPL-2.0, AGPL-3.0
          comment-summary-in-pr: always

Layer 2: Dependency Security

The Dependency Confusion Attack

In a dependency confusion attack, a public malicious package with the same name as an internal package gets installed from the public registry instead.

Defenses:

# Pin exact versions in lock files (npm)
npm ci  <span class="hljs-comment"># Uses package-lock.json exactly

<span class="hljs-comment"># Use package scoping for internal packages
<span class="hljs-comment"># @mycompany/internal-lib instead of internal-lib

<span class="hljs-comment"># Configure registry for scoped packages (.npmrc)
@mycompany:registry=https://npm.mycompany.com

<span class="hljs-comment"># Verify packages have expected owners
npm owner <span class="hljs-built_in">ls react

Scanning Dependencies at Multiple Levels

# Scan lock files (fastest, catches what you install)
osv-scanner --lockfile package-lock.json

<span class="hljs-comment"># Scan installed packages (catches transitive deps)
pip install pip-audit
pip-audit

<span class="hljs-comment"># Scan container image (catches OS + app packages)
trivy image myapp:latest

<span class="hljs-comment"># Check for outdated dependencies
npm outdated
pip list --outdated

Supply Chain Specific Tools

# socket.sh - detects malicious npm packages
npm install -g @socketsecurity/cli
socket scan .

<span class="hljs-comment"># Snyk for SCA
snyk <span class="hljs-built_in">test
snyk container <span class="hljs-built_in">test myapp:latest

<span class="hljs-comment"># Semgrep for supply chain patterns
semgrep --config <span class="hljs-string">"p/supply-chain" .

Layer 3: Build System Security

Isolating Builds

The build environment is high-value target. Controls:

# Use ephemeral, minimal build environments
jobs:
  build:
    runs-on: ubuntu-latest  # Fresh VM per job
    container:
      image: golang:1.22-alpine  # Minimal base image
    
    steps:
      # Pin actions to SHA, not tag (tags are mutable)
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
      - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722034518b972  # v5.0.2
      
      # Verify third-party tools before using them
      - name: Download and verify tool
        run: |
          curl -sSfL https://example.com/tool -o tool
          echo "expected_sha256sum  tool" | sha256sum --check
          chmod +x tool

Pinning Actions to SHA

Action tags like @v4 can be moved to point to different code:

# Insecure - tag can be moved
- uses: actions/checkout@v4

# Secure - SHA is immutable
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683

# Use a tool to manage this
# dependabot will automatically update pinned SHAs

Use pin-github-action or Dependabot to automate SHA management.

Restricting Build Permissions

# Minimum permissions for CI jobs
permissions:
  contents: read  # Only what's needed
  # Don't grant packages:write unless building containers
  # Don't grant issues:write unless needed
  # id-token:write only for OIDC (Cosign/Sigstore)

Detecting Exfiltration Attempts

Build systems can be used to exfiltrate secrets. Monitor for:

# GitHub Actions: use allowed-endpoint filtering
- uses: step-security/harden-runner@v2
  with:
    egress-policy: audit  # Log outbound connections
    # egress-policy: block  # Block unexpected endpoints
    allowed-endpoints: >
      api.github.com:443
      registry.npmjs.org:443
      objects.githubusercontent.com:443

Layer 4: Artifact Integrity

SBOM Generation

Generate an SBOM for every artifact you ship:

# Container image SBOM
syft myapp:v1.2.3 -o spdx-json > sbom.spdx.json
syft myapp:v1.2.3 -o cyclonedx-json > sbom.cdx.json

<span class="hljs-comment"># Binary SBOM
syft myapp-linux-amd64 -o spdx-json > sbom.spdx.json

Artifact Signing

Sign every artifact at build time:

- name: Sign container image
  run: |
    cosign sign --yes \
      myapp@${{ steps.build.outputs.digest }}

- name: Sign binary
  run: |
    cosign sign-blob --yes \
      --bundle myapp-linux-amd64.bundle \
      myapp-linux-amd64

Provenance Generation

Generate SLSA provenance to record build details:

# Use slsa-github-generator for SLSA 3 provenance
provenance:
  needs: build
  uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v1.10.0
  with:
    image: myapp
    digest: ${{ needs.build.outputs.digest }}

Layer 5: Registry Security

Image Pull Policy

Never use latest in production:

# Kubernetes deployment
containers:
  - name: myapp
    image: ghcr.io/myorg/myapp@sha256:abc123...  # Pin to digest
    imagePullPolicy: IfNotPresent

Admission Control

Reject unsigned or untrusted images at the cluster level:

# Sigstore Policy Controller
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
  name: require-signatures
spec:
  images:
    - glob: "ghcr.io/myorg/**"
  authorities:
    - keyless:
        url: https://fulcio.sigstore.dev
        identities:
          - issuer: "https://token.actions.githubusercontent.com"
            subjectRegExp: "https://github.com/myorg/.*"

Layer 6: Continuous Monitoring

Supply chain security isn't a one-time check — vulnerabilities are discovered constantly.

Scheduled Rescanning

# .github/workflows/security-rescan.yaml
name: Weekly Security Rescan

on:
  schedule:
    - cron: '0 8 * * 1'  # Monday 8 AM

jobs:
  rescan:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        image:
          - ghcr.io/myorg/myapp:v1.2.3
          - ghcr.io/myorg/myapp:v1.2.2
    steps:
      - name: Pull latest vuln DB
        run: grype db update

      - name: Scan image
        run: |
          grype ${{ matrix.image }} \
            --fail-on critical \
            -o json > results.json

      - name: Notify if critical found
        if: failure()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {"text": "Critical vulnerability found in ${{ matrix.image }}"}
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

SBOM-Based Monitoring

Store SBOMs and rescan them against updated vulnerability databases:

#!/bin/bash
<span class="hljs-comment"># rescan-all-sboms.sh

SBOM_DIR=/storage/sboms

<span class="hljs-keyword">for sbom <span class="hljs-keyword">in <span class="hljs-string">"$SBOM_DIR"/*.spdx.json; <span class="hljs-keyword">do
  <span class="hljs-built_in">echo <span class="hljs-string">"Scanning: $sbom"
  version=$(<span class="hljs-built_in">basename <span class="hljs-string">"$sbom" .spdx.json)
  
  grype <span class="hljs-string">"sbom:$sbom" \
    --fail-on critical \
    -o json > <span class="hljs-string">"/tmp/${version}-vulns.json"
  
  CRITICAL=$(jq <span class="hljs-string">'[.matches[] | select(.vulnerability.severity=="Critical")] <span class="hljs-pipe">| length' \
    <span class="hljs-string">"/tmp/${version}-vulns.json")
  
  <span class="hljs-keyword">if [ <span class="hljs-string">"$CRITICAL" -gt 0 ]; <span class="hljs-keyword">then
    <span class="hljs-built_in">echo <span class="hljs-string">"ALERT: $CRITICAL critical vulnerabilities in <span class="hljs-variable">$version"
    <span class="hljs-comment"># Send alert...
  <span class="hljs-keyword">fi
<span class="hljs-keyword">done

Testing Your Supply Chain Controls

The controls are only as good as your testing. Build specific tests for each layer:

Test: Can an Unsigned Image Be Deployed?

# Attempt to deploy a known-unsigned image
kubectl run unsigned-test \
  --image=nginx:latest \
  --namespace=production

<span class="hljs-comment"># Expected: blocked by admission controller
<span class="hljs-comment"># grep for rejection message in output

Test: Does Dependency Scanning Catch Vulnerabilities?

# Create a test project with a known-vulnerable dependency
<span class="hljs-built_in">mkdir /tmp/vuln-test
<span class="hljs-built_in">cat > /tmp/vuln-test/requirements.txt << <span class="hljs-string">EOF
requests==2.6.0  # CVE-2018-18074
EOF

osv-scanner --lockfile /tmp/vuln-test/requirements.txt
<span class="hljs-comment"># Expected: vulnerability report for CVE-2018-18074

<span class="hljs-comment"># Verify CI would fail
<span class="hljs-keyword">if osv-scanner --lockfile /tmp/vuln-test/requirements.txt; <span class="hljs-keyword">then
  <span class="hljs-built_in">echo <span class="hljs-string">"FAIL: scanner should have failed"
  <span class="hljs-built_in">exit 1
<span class="hljs-keyword">else
  <span class="hljs-built_in">echo <span class="hljs-string">"PASS: scanner correctly detected vulnerability"
<span class="hljs-keyword">fi

Test: Is Provenance Verifiable?

# Verify provenance of your latest production image
slsa-verifier verify-image \
  ghcr.io/myorg/myapp:v1.2.3 \
  --source-uri github.com/myorg/myapp \
  --source-tag v1.2.3

<span class="hljs-comment"># Expected: "Verified SLSA provenance"

Test: SBOM Completeness

#!/usr/bin/env python3
"""Test that SBOM contains all expected packages"""
import json
import subprocess
import sys

# Generate fresh SBOM
result = subprocess.run(
    ['syft', 'myapp:latest', '-o', 'cyclonedx-json'],
    capture_output=True, text=True
)
sbom = json.loads(result.stdout)

# Check expected packages are present
expected = {'openssl', 'python3', 'requests'}
found = {c['name'].lower() for c in sbom.get('components', [])}

missing = expected - found
if missing:
    print(f"SBOM missing expected packages: {missing}")
    sys.exit(1)
print(f"SBOM completeness check passed ({len(found)} packages)")

The Minimal Viable Supply Chain Security Program

If you're starting from zero, implement in this order:

Week 1: Enable dependency scanning

  • Add Grype or Trivy to CI
  • Fail on critical severity
  • Don't suppress anything yet (accept the noise)

Week 2: SBOM generation

  • Generate SBOMs for all container builds
  • Store them as build artifacts
  • No enforcement yet

Week 3: Image signing

  • Sign all container images with Cosign
  • Verify signatures in staging deployment

Week 4: Admission control

  • Deploy Sigstore Policy Controller or Kyverno
  • Enforce signature verification in non-production first
  • Roll out to production after stabilizing

Month 2: SLSA provenance

  • Add slsa-github-generator to release workflows
  • Aim for SLSA 2 initially
  • Add slsa-verifier to deployment pipeline

Month 3+: Continuous monitoring

  • Scheduled rescanning of historical SBOMs
  • Alerting on new critical CVEs in deployed versions
  • VEX statements for false positive management

Common Mistakes

Signing without verifying: Sign images in CI, but verify nothing at deploy time. Signing is useless without enforcement.

Suppressing everything: Teams get overwhelmed by scanner noise and suppress all findings. Audit suppression lists quarterly.

Tag pinning without SHA pinning: Tagging containers with v1.2.3 isn't immutable. Pin to SHA digests for production.

Trusting the build environment unconditionally: If your CI environment is compromised, signed artifacts can still be malicious. Defense in depth is necessary.

One-time scanning: Scanning at build time only. Vulnerabilities disclosed after your build are invisible until the next build. Schedule regular rescans.

Supply chain security is not a product you buy once — it's a practice you build incrementally. Start with the highest-signal controls (dependency scanning, image signing) and layer in more sophisticated controls over time. The goal isn't perfection; it's making attacks measurably harder and detectable earlier.

Read more