SBOM & Supply-Chain Signing
TL;DR
A Software Bill of Materials (SBOM) is a complete list of all components and dependencies in a software artifact. Supply-chain security ensures that artifacts are built by trusted builders and haven't been tampered with. Cosign (from sigstore) enables keyless cryptographic signing of artifacts using OIDC identity. SLSA (Supply-chain Levels for Software Artifacts) is a framework defining maturity levels for supply-chain security. Verify artifact provenance (who built it, from what code) before deployment. Require SBOM, signatures, and attestations at the image registry admission layer.
Learning Objectives
- Generate and understand Software Bills of Materials (SBOM) for containers
- Implement image signing using cosign and keyless identity (OIDC)
- Verify attestations and provenance in CI/CD and admission controllers
- Evaluate and implement SLSA supply-chain security levels
- Detect and prevent compromised artifacts from reaching production
- Build verifiable provenance chains from source code to running container
Motivating Scenario
A DevOps team pulls a container image from a public registry: node:18.0.0-alpine. They assume it's the official Node image, but it's actually a compromise: an attacker created a near-identical image with a cryptominer injected. No one noticed. The cryptominer silently runs on every server. Months later, performance drops and a security audit discovers it.
With supply-chain security: Image must be signed by Node.js maintainers' key. SBOM must list all dependencies. Before deployment, Kubernetes admission controller verifies: "Is this image signed? Does SBOM match expected dependencies?" Compromised image is rejected immediately.
Core Concepts
SBOM (Software Bill of Materials)
An SBOM lists all components in an artifact:
- Direct dependencies (packages explicitly used)
- Transitive dependencies (dependencies of dependencies)
- Versions and build info
- License information
- Known vulnerabilities
Example SBOM entry: pkg:npm/lodash@4.17.21 (NPM package, lodash, version 4.17.21)
Formats: SPDX, CycloneDX, SWID
Artifact Signing & Attestation
Signature: Cryptographic proof that an artifact came from a specific key/identity.
Attestation: Structured metadata signed alongside the artifact. Examples:
- "Built by cosign version 1.12.0"
- "Built from Git commit abc123def456"
- "Passed security scan with 0 vulnerabilities"
- "SBOM generated by syft"
Cosign & Keyless Signing
Cosign uses OIDC (OpenID Connect) to sign artifacts without managing keys:
- Build system proves identity via OIDC (e.g., GitHub Actions, GitLab CI)
- Cosign generates a short-lived certificate from OIDC token
- Image is signed with that certificate
- Certificate is verifiable against OIDC issuer's public keys
- No private key storage needed (keyless = simpler + safer)
SLSA Framework
SLSA defines 4 levels of supply-chain security maturity:
| Level | Requirements | Effort |
|---|---|---|
| L0 | Minimal | Scripted build, no attestation |
| L1 | Versioned + provenance | Automated build, signed provenance |
| L2 | Hermetic + hardened | Isolated build, no external network, script reviewed |
| L3 | Hardened + locked-down | Reproducible builds, source integrity verified |
| L4 | Maximum security | Full audit trail, offline signing keys, 2FA |
Most organizations target L2-L3.
Practical Example
- Generate SBOM
- Sign Images with Cosign
- Verify at Deployment Time
- Supply-Chain Audit
#!/bin/bash
# Generate SBOM for container image using syft
# Install syft (https://github.com/anchore/syft)
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
# Generate SBOM in SPDX format
syft myregistry.azurecr.io/myapp:1.2.3 -o spdx > sbom.spdx.json
# Or CycloneDX format
syft myregistry.azurecr.io/myapp:1.2.3 -o cyclonedx > sbom.cyclonedx.xml
# View SBOM
jq . sbom.spdx.json | head -50
# Output sample:
# {
# "spdxVersion": "SPDX-2.3",
# "creationInfo": {
# "created": "2025-02-14T10:00:00Z",
# "creators": ["Tool: syft-0.68.3"]
# },
# "name": "myapp:1.2.3",
# "packages": [
# {
# "SPDXID": "SPDXRef-Package-node-modules-lodash",
# "name": "lodash",
# "versionInfo": "4.17.21",
# "downloadLocation": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
# "filesAnalyzed": false
# },
# {
# "SPDXID": "SPDXRef-Package-npm-express",
# "name": "express",
# "versionInfo": "4.18.2",
# "downloadLocation": "https://registry.npmjs.org/express/-/express-4.18.2.tgz"
# }
# ]
# }
# Publish SBOM to registry (attach to image)
cosign attach sbom --sbom sbom.spdx.json myregistry.azurecr.io/myapp:1.2.3
Usage:
- Generate during build
- Publish alongside image
- Query at deployment time: "What's in this image?"
#!/bin/bash
# Sign container images using cosign (keyless with OIDC)
# Step 1: Install cosign
# curl -Lo /usr/local/bin/cosign https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64
# chmod +x /usr/local/bin/cosign
# Step 2: Sign image with keyless identity (GitHub Actions example)
# This is typically in your CI/CD pipeline, not manual
export COSIGN_EXPERIMENTAL=1 # Enable keyless signing
# In GitHub Actions:
# - env.GITHUB_TOKEN is automatically available
# - cosign uses OIDC to prove identity
# - No private key management needed
cosign sign --yes myregistry.azurecr.io/myapp:1.2.3
# This prompts: "Are you sure you want to sign myapp:1.2.3?"
# Signs with OIDC identity from GitHub Actions
# Certificate stored in transparent log (Rekor)
# Step 3: Attach attestation (build provenance)
cosign attach attestation --attestation provenance.json myregistry.azurecr.io/myapp:1.2.3
# Step 4: Verify signature
cosign verify --certificate-identity "https://github.com/myorg/myrepo/.github/workflows/build.yml@refs/heads/main" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
myregistry.azurecr.io/myapp:1.2.3
# Output:
# Verification successful!
# Verified by token.actions.githubusercontent.com
In GitHub Actions CI/CD:
name: Build and Sign Image
on: [push]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # Required for OIDC
steps:
- uses: actions/checkout@v3
- name: Build image
run: |
docker build -t myregistry.azurecr.io/myapp:${{ github.sha }} .
docker push myregistry.azurecr.io/myapp:${{ github.sha }}
- name: Generate SBOM
run: |
syft myregistry.azurecr.io/myapp:${{ github.sha }} -o spdx > sbom.spdx.json
cosign attach sbom --sbom sbom.spdx.json myregistry.azurecr.io/myapp:${{ github.sha }}
- name: Sign image
env:
COSIGN_EXPERIMENTAL: 1 # Use OIDC
run: |
cosign sign --yes myregistry.azurecr.io/myapp:${{ github.sha }}
- name: Create provenance attestation
run: |
echo '{
"buildType": "https://github.com/actions/build",
"builder": {
"id": "github-actions"
},
"sourceRepository": "${{ github.server_url }}/${{ github.repository }}",
"sourceCommit": "${{ github.sha }}"
}' > provenance.json
cosign attach attestation --attestation provenance.json myregistry.azurecr.io/myapp:${{ github.sha }}
# Kubernetes admission controller: verify signatures before deployment
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: verify-image-signatures
webhooks:
- name: verify.sigstore.io
clientConfig:
service:
name: cosign-verifier
namespace: kube-system
path: "/verify"
caBundle: LS0tLS1CRUdJTi... # base64 CA cert
rules:
- operations: ["CREATE", "UPDATE"]
apiGroups: [""]
apiVersions: ["v1"]
resources: ["pods"]
scope: "Namespaced"
failurePolicy: Fail # Reject if verification fails
admissionReviewVersions: ["v1"]
sideEffects: None
---
# Example webhook logic (pseudo-code):
# For each pod being created:
# 1. Extract image reference from pod spec
# 2. Call cosign verify:
# cosign verify --certificate-identity <TRUSTED_IDENTITY> \
# --certificate-oidc-issuer <ISSUER> \
# <IMAGE>
# 3. If verification fails, reject pod
# 4. If passes, allow pod to be created
# Pod that passes verification (signed by trusted builder)
apiVersion: v1
kind: Pod
metadata:
name: signed-app
spec:
containers:
- name: app
image: myregistry.azurecr.io/myapp:v1.2.3
# This image is signed by GitHub Actions
# Admission controller verifies signature
# Pod is ACCEPTED
---
# Pod that fails verification (unsigned image)
apiVersion: v1
kind: Pod
metadata:
name: unsigned-app
spec:
containers:
- name: app
image: docker.io/library/node:18
# This image may not be signed (not in your policy)
# Admission controller: cannot verify signature
# Pod is REJECTED
# Error: "Image signature verification failed"
Policy Example:
Require all images to be:
- Signed by GitHub Actions (OIDC issuer:
https://token.actions.githubusercontent.com) - Built from your organization's repos
- Include SBOM attestation
- Zero critical vulnerabilities
#!/usr/bin/env python3
# Audit supply-chain security across all deployed images
import subprocess
import json
from typing import List, Dict
class SupplyChainAudit:
"""Audit container images for proper signing and SBOMs."""
def __init__(self, cluster: str):
self.cluster = cluster
def get_all_images(self) -> List[str]:
"""Get all container images currently deployed."""
cmd = "kubectl get pods -A -o json"
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
pods = json.loads(result.stdout)
images = set()
for pod in pods["items"]:
for container in pod["spec"]["containers"]:
images.add(container["image"])
return list(images)
def verify_image_signed(self, image: str) -> bool:
"""Check if image has cosign signature."""
cmd = f"cosign verify {image} 2>&1"
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
return result.returncode == 0
def get_image_sbom(self, image: str) -> Dict:
"""Retrieve SBOM attestation."""
cmd = f"cosign tree {image} 2>&1"
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if result.returncode == 0:
return json.loads(result.stdout)
return {}
def audit_all_images(self) -> Dict:
"""Audit all deployed images."""
images = self.get_all_images()
results = {
"total_images": len(images),
"signed": 0,
"unsigned": [],
"images": {}
}
for image in images:
is_signed = self.verify_image_signed(image)
sbom = self.get_image_sbom(image) if is_signed else None
results["images"][image] = {
"signed": is_signed,
"sbom_present": sbom is not None,
}
if is_signed:
results["signed"] += 1
else:
results["unsigned"].append(image)
return results
def generate_report(self, audit: Dict) -> str:
"""Generate audit report."""
signed_count = audit["signed"]
unsigned_count = len(audit["unsigned"])
total = audit["total_images"]
report = f"""
=== SUPPLY-CHAIN SECURITY AUDIT ===
Cluster: {self.cluster}
Audit Date: $(date)
SUMMARY:
- Total images: {total}
- Signed: {signed_count} ({signed_count*100//total}%)
- Unsigned: {unsigned_count} ({unsigned_count*100//total}%)
RISK ASSESSMENT:
"""
if unsigned_count == 0:
report += "✓ ALL IMAGES SIGNED (Excellent)"
elif unsigned_count < total * 0.1:
report += "⚠ MOST IMAGES SIGNED (Good)"
else:
report += "✗ MANY UNSIGNED IMAGES (Risk)"
if audit["unsigned"]:
report += "\n\nUNSIGNED IMAGES:\n"
for img in audit["unsigned"]:
report += f" - {img}\n"
return report
# Usage
audit = SupplyChainAudit(cluster="production")
results = audit.audit_all_images()
print(audit.generate_report(results))
# Output:
# === SUPPLY-CHAIN SECURITY AUDIT ===
# Cluster: production
# SUMMARY:
# - Total images: 42
# - Signed: 38 (90%)
# - Unsigned: 4 (10%)
#
# RISK ASSESSMENT:
# ⚠ MOST IMAGES SIGNED (Good)
#
# UNSIGNED IMAGES:
# - docker.io/library/redis:latest
# - docker.io/library/postgres:13
# - gcr.io/kaniko-project/executor:latest
# - quay.io/prometheus/prometheus:v2.41.0
Automated Response:
If unsigned image detected:
- Alert on Slack: "Unsigned image deployed"
- Send PagerDuty alert to on-call security team
- Create ticket: "Verify and sign image, or remove from cluster"
- Optionally quarantine/evict pod
When to Use / When NOT to Use
- DO: Sign All Images Built by Your Organization: CI/CD signs every image with OIDC (GitHub Actions, GitLab CI). Signature verifiable at deployment. No key management burden.
- DO: Require Verification at Admission Time: Kubernetes admission controller verifies every image: signature present, signed by trusted builder. Rejected if unsigned.
- DO: Generate and Publish SBOM: Build generates SBOM (syft). Publish alongside image. At deployment, verify SBOM lists expected dependencies.
- DO: Use OIDC (Keyless Signing): Cosign + OIDC = no private keys to manage. GitHub Actions proves identity. Certificate valid for seconds. Simpler + safer.
- DO: Define Trusted Builders: Admission policy: only accept images signed by GitHub Actions from your org repos. Prevents supply-chain compromise.
- DO: Audit Supply-Chain Regularly: Monthly: scan all deployed images for signatures + SBOM. Report unsigned images. Drive adoption.
- DO: Sign All Images Built by Your Organization: Skip signing because 'it's extra work.' Or manually sign images (error-prone, slow). Or rely on registry username/password only.
- DO: Require Verification at Admission Time: Trust that teams will use only 'good' images. Someone will inevitably deploy an unsigned image. Compromise slides through.
- DO: Generate and Publish SBOM: No SBOM. Can't answer: what's in this image? Can't detect dependency changes. Vulnerable package sneaks in undetected.
- DO: Use OIDC (Keyless Signing): Manage private keys manually (risk of exposure). Or use hardcoded signing keys (single point of failure).
- DO: Define Trusted Builders: Accept images signed by anyone. Or trust images without signatures. Attacker can push signed malicious image.
- DO: Audit Supply-Chain Regularly: Set policy once, never check compliance. Unsigned images creep in. Policy becomes unenforceable myth.
Patterns & Pitfalls
Design Review Checklist
- Are all container images built by your organization signed?
- Is keyless signing (OIDC) enabled to avoid key management?
- Are SBOMs generated for every image build?
- Are SBOMs published to the registry (attached to image)?
- Is there a Kubernetes admission controller verifying signatures?
- Does admission policy define trusted builders (OIDC issuers)?
- Are third-party images (Redis, Postgres, etc.) trusted and pinned to specific versions?
- Are attestations generated beyond signatures (scan results, provenance)?
- Is supply-chain compliance audited monthly (% signed images)?
- Are unsigned images detected and teams notified?
- Is SLSA level documented and progressive improvement planned?
- Are SBOMs queryable (can you list all images with lodash@4.17.21)?
- Is image tampering detectable (signature verification before runtime)?
- Are compromised artifacts recoverable (can you identify deployed images)?
- Is supply-chain security integrated into CI/CD (not manual)?
Self-Check
- Right now, do all your images have cosign signatures? Check with:
cosign verify myimage:tag - Which images are unsigned? Run audit script. If count > 0, that's risk.
- Can you list all dependencies in your images? Query SBOM. If not, generate them.
- If a dependency is vulnerable, can you identify which images are affected? SBOM makes this quick.
- What would you do if your signing key was compromised? Have a rotation plan.
Next Steps
- Enable OIDC signing — Add cosign to your CI/CD. Sign images with OIDC.
- Generate SBOMs — Add syft to build. Attach SBOM to every image.
- Implement admission control — Deploy webhook to verify signatures.
- Define trust policy — What builders are trusted? Which images allowed?
- Audit compliance — Monthly report: % of images signed and with SBOM.
- Add attestations — Beyond signatures, attest to: vulnerability scans, SLSA level, build provenance.
- Plan SLSA progression — Define path from L1 → L2 → L3.
References
- Sigstore: Keyless Container Signing ↗️
- Cosign: GitHub Repository ↗️
- SLSA: Supply-Chain Security Framework ↗️
- Syft: SBOM Generator ↗️
- CycloneDX: SBOM Format Standard ↗️
- SPDX: Software Package Data Exchange ↗️