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 injectionEach 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 HEADBranch 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: trueDependency 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: alwaysLayer 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 reactScanning 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 --outdatedSupply 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 toolPinning 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 SHAsUse 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:443Layer 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.jsonArtifact 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-amd64Provenance 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: IfNotPresentAdmission 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">doneTesting 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 outputTest: 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">fiTest: 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.