What You'll Build
In this lab you will implement the complete container supply chain security pipeline that transforms a passing CI build into a cryptographically attested, registry-signed, admission-webhook-enforced deployment artefact. The pipeline extends the GitHub Actions workflow from M1 Lesson 7 with four new supply chain steps: SBOM generation using Syft in CycloneDX format, SBOM signing and attachment to the image digest as an OCI referrer using Cosign, SLSA provenance attestation using the slsa-github-generator reusable workflow, and Kyverno ClusterPolicy deployment that enforces signature verification at pod admission time. When complete, any attempt to deploy an unsigned or improperly attested image to the target Kubernetes cluster will be rejected by the admission webhook with a descriptive policy violation message, completing the supply chain integrity chain from source commit to running pod.
The structure of this lab is designed so that each step adds a new, independently testable supply chain property: the SBOM step adds software composition transparency; the Cosign signing step adds identity-bound tamper evidence; the SLSA provenance step adds build-process verifiability; and the Kyverno enforcement step converts all three from documentation to enforcement. Each step can be verified independently before the next is added, which is the correct incremental rollout strategy for supply chain controls in a live environment — replacing all four simultaneously with a single Kyverno Enforce policy would immediately break all deployments that had not yet been signed, while the incremental approach allows each control to be validated before its enforcement is activated.
Prerequisites
- The working GitHub Actions pipeline from M1 Lesson 7 that builds, scans with Trivy, and pushes to ECR — this lab adds four new jobs to that workflow rather than replacing it.
- A Kubernetes cluster with Kyverno installed — `kubectl apply -f https://github.com/kyverno/kyverno/releases/latest/download/install.yaml` installs Kyverno in the `kyverno` namespace with default configuration.
- Cosign v2 installed and `cosign tree` verified working against the ECR repository from M1 — confirms the ECR registry supports OCI referrers API used for signature and attestation storage.
- Syft v0.100+ installed locally — `brew install syft` on macOS; used to verify SBOM generation locally before the CI step is added.
- The `slsa-github-generator` reusable workflow accessible from your repository — requires the GitHub Actions OIDC permission and a GitHub App token with workflow permissions for cross-repository workflow calls.
Setup & Project Structure
Verify the baseline pipeline from M1 Lesson 7 produces a clean Trivy scan and a successfully pushed image before adding any supply chain steps. Adding SBOM generation and signing to a pipeline that has existing failures creates compound debugging problems where the new failure may mask the existing one. The correct engineering discipline is: establish a clean baseline, verify it, then add each new step one at a time, verifying again after each addition.
# Verify baseline pipeline before extending with supply chain steps
# ── 1. Confirm ECR image exists from M1 Lab ───────────────────────────────
ECR_REGISTRY="123456789.dkr.ecr.ap-south-1.amazonaws.com"
ECR_REPO="ipl-scorecard"
aws ecr describe-images --repository-name $ECR_REPO --region ap-south-1 --query 'imageDetails[?contains(imageTags, `latest`)].[imageDigest,imagePushedAt]' --output table
# Must show at least one image tagged 'latest' before proceeding
# ── 2. Verify Cosign can query ECR referrers API ──────────────────────────
IMAGE="$ECR_REGISTRY/$ECR_REPO:latest"
cosign tree $IMAGE
# If this returns "WARN: no signatures associated" rather than an auth error,
# the baseline is correct — no signatures yet, but ECR supports the referrers API
# ── 3. Generate SBOM locally to test Syft before CI integration ──────────
syft $IMAGE --output cyclonedx-json=local-test-sbom.json
# Must produce a valid CycloneDX JSON — check component count
python3 -c "
import json
with open('local-test-sbom.json') as f: sbom = json.load(f)
print(f'SBOM components: {len(sbom.get("components", []))}')
# Expected: 50-200 components depending on python:3.11-slim base
"
# ── 4. Install Kyverno in the test cluster (kind or minikube) ─────────────
kubectl apply -f https://github.com/kyverno/kyverno/releases/latest/download/install.yaml
kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=kyverno -n kyverno --timeout=120s
echo "Kyverno ready ✓"
# ── 5. Deploy Kyverno policy in Audit mode first ──────────────────────────
# (defined in Step 3 below — deploy here to capture Audit violations during lab)
echo "Baseline verified — ready to add supply chain steps" Step 1 — Foundation
Add the SBOM generation step to the GitHub Actions workflow immediately after the Docker push step, using Syft to generate a CycloneDX SBOM and Cosign to attach it to the image as an OCI artifact. The SBOM attachment stores the SBOM in the same ECR repository as the image, addressable through the OCI referrers API at the image's digest, without any additional storage configuration. Setting up SBOM generation before signature generation is deliberate — the SBOM is input to the vulnerability attestation in a later step, and generating it first confirms that ECR's referrers API is accessible from the CI runner before adding the more complex signing ceremony.
# Step 1: Add SBOM generation to the existing GitHub Actions workflow
# Append these jobs to .github/workflows/build-scan-push.yml
# after the existing push and Cosign signing steps
# ── SBOM Generation and Attachment ─────────────────────────────────
- name: Install Syft for SBOM generation
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin v1.0.1
syft version
- name: Generate CycloneDX SBOM
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
ECR_REGISTRY: ${{ steps.ecr_login.outputs.registry }}
run: |
# Generate SBOM from the pushed image (pulls from ECR for accuracy)
syft $ECR_REGISTRY/ipl-scorecard@${{ env.IMAGE_DIGEST }} --output cyclonedx-json=ipl-sbom.cyclonedx.json --output spdx-json=ipl-sbom.spdx.json
# Report component count for audit visibility
COMPONENT_COUNT=$(python3 -c "
import json
with open('ipl-sbom.cyclonedx.json') as f: sbom = json.load(f)
print(len(sbom.get('components', [])))
")
echo "SBOM generated: $COMPONENT_COUNT components" >> $GITHUB_STEP_SUMMARY
- name: Attach SBOM to image digest as OCI artifact
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
ECR_REGISTRY: ${{ steps.ecr_login.outputs.registry }}
run: |
# Attach CycloneDX SBOM to the image in ECR as an OCI referrer
cosign attach sbom --sbom ipl-sbom.cyclonedx.json --type cyclonedx $ECR_REGISTRY/ipl-scorecard@${{ env.IMAGE_DIGEST }}
# Verify the SBOM referrer was recorded
cosign tree $ECR_REGISTRY/ipl-scorecard@${{ env.IMAGE_DIGEST }}
- name: Attach vulnerability scan as attestation
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
ECR_REGISTRY: ${{ steps.ecr_login.outputs.registry }}
run: |
# Generate a vulnerability scan report in Cosign's attestation format
trivy image --format cosign-vuln --output vuln-attestation.json $ECR_REGISTRY/ipl-scorecard@${{ env.IMAGE_DIGEST }}
# Attach as a signed attestation (not just an attached artifact)
cosign attest --yes --predicate vuln-attestation.json --type vuln $ECR_REGISTRY/ipl-scorecard@${{ env.IMAGE_DIGEST }}Step 2 — Core Logic
Add the SLSA provenance generation step using the `slsa-github-generator` reusable workflow, and update the Kyverno ClusterPolicy from Audit to Enforce mode. The SLSA provenance generator runs as a separate GitHub Actions job that calls a reusable workflow maintained by the Sigstore project, producing a SLSA Level 3 provenance attestation that records the source repository, commit SHA, workflow definition, and builder identity. Switching Kyverno from Audit to Enforce is a one-field change that activates the enforcement gate — this step should only be done after verifying that the current workflow produces valid signatures and attestations for every commit on the main branch, which can be confirmed by checking the Kyverno Audit policy report for zero violations.
# Step 2: SLSA provenance + Kyverno policy update
# Full updated workflow: .github/workflows/build-scan-push.yml
# ── Add SLSA provenance generation job ─────────────────────────────────────
# This is a SEPARATE job that calls the slsa-github-generator reusable workflow
# It MUST be a separate job due to how the generator accesses the OIDC token
# (separate from the build-scan-push job)
slsa-provenance:
needs: [build-scan-push] # runs after the image is pushed
permissions:
id-token: write # for OIDC token used in signing
contents: read
actions: read # for accessing workflow run information
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v1.10.0
with:
image: 123456789.dkr.ecr.ap-south-1.amazonaws.com/ipl-scorecard
digest: ${{ needs.build-scan-push.outputs.image_digest }}
registry-username: AWS
secrets:
registry-password: ${{ secrets.ECR_PASSWORD }}
# ── Update the Kyverno ClusterPolicy to Enforce mode ──────────────────────
# After verifying zero Audit violations, apply the updated policy
# kyverno-ipl-scorecard-policy.yaml — production enforce mode
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-ipl-scorecard-signature
spec:
validationFailureAction: Enforce # CHANGED from Audit to Enforce
background: true
rules:
- name: check-image-signature
match:
any:
- resources: {kinds: [Pod]}
verifyImages:
- imageReferences:
- "123456789.dkr.ecr.ap-south-1.amazonaws.com/ipl-scorecard:*"
attestors:
- count: 1
entries:
- keyless:
subject: "https://github.com/org/ipl-scorecard/.github/workflows/build-scan-push.yml@refs/heads/main"
issuer: "https://token.actions.githubusercontent.com"
attestations:
- predicateType: https://slsa.dev/provenance/v0.2
conditions:
- all:
- key: "{{ predicate.invocation.configSource.uri }}"
operator: Equals
value: "git+https://github.com/org/ipl-scorecard@refs/heads/main"
- predicateType: https://cosign.sigstore.dev/attestation/vuln/v1
conditions:
- all:
- key: "{{ predicate.scanner.name }}"
operator: Equals
value: "Trivy" # must have been scanned by Trivy
# Apply the Enforce policy
kubectl apply -f kyverno-ipl-scorecard-policy.yaml
# Verify: confirm current production deployment still passes
kubectl get cpol require-ipl-scorecard-signature -o yaml | grep validationFailureActionStep 3 — Integration & Enhancement
Verify the complete supply chain from end to end: confirm all OCI referrers are present for the latest image, verify the Kyverno policy blocks an unsigned image, and use `cosign verify-attestation` to inspect the SLSA provenance and vulnerability attestation contents. This verification sequence traces the complete chain of trust from the CI signing event through the transparency log to the admission webhook, confirming that every link in the chain is intact and that the enforcement gate correctly distinguishes between signed and unsigned images.
# End-to-end supply chain verification
ECR="123456789.dkr.ecr.ap-south-1.amazonaws.com"
IMAGE="$ECR/ipl-scorecard:latest"
DIGEST=$(docker inspect $IMAGE --format '{{index .RepoDigests 0}}' | cut -d@ -f2)
# ── 1. Inspect the full OCI referrer graph ────────────────────────────────
cosign tree $IMAGE
# Expected tree:
# └── sha256:abc123 (ipl-scorecard:latest)
# ├── sha256:def456 (application/vnd.dev.cosign.artifact.sig.v1+json — signature)
# ├── sha256:ghi789 (application/vnd.cyclonedx — SBOM attachment)
# ├── sha256:jkl012 (application/vnd.dev.cosign.attestation.v1+json — SLSA provenance)
# └── sha256:mno345 (application/vnd.dev.cosign.attestation.v1+json — vuln scan)
# ── 2. Verify Cosign signature ────────────────────────────────────────────
cosign verify --certificate-identity-regexp "https://github.com/org/ipl-scorecard.*" --certificate-oidc-issuer "https://token.actions.githubusercontent.com" $ECR/ipl-scorecard@$DIGEST
echo "Signature verification: PASS ✓"
# ── 3. Inspect SLSA provenance attestation ────────────────────────────────
cosign verify-attestation --certificate-identity-regexp "https://github.com/org/ipl-scorecard.*" --certificate-oidc-issuer "https://token.actions.githubusercontent.com" --type slsaprovenance $ECR/ipl-scorecard@$DIGEST | jq '.payload | @base64d | fromjson | {
builder: .predicate.builder.id,
source: .predicate.invocation.configSource.uri,
commit: .predicate.invocation.configSource.digest.sha1,
buildTime: .predicate.metadata.buildFinishedOn
}'
# Expected: source and commit link back to the correct repo and commit
# ── 4. Test Kyverno enforcement: unsigned image must be blocked ───────────
# Push an unsigned test image (skip the signing step)
docker tag python:3.11-slim $ECR/ipl-scorecard:unsigned-test
docker push $ECR/ipl-scorecard:unsigned-test
# Attempt to create a Pod using the unsigned image — must be blocked
kubectl run unsigned-pod --image=$ECR/ipl-scorecard:unsigned-test --dry-run=server # server-side dry run runs admission webhooks
# Expected: Error from server (Forbidden): pods "unsigned-pod" is forbidden:
# policy require-ipl-scorecard-signature/check-image-signature:
# image verification failed for .../ipl-scorecard:unsigned-test
echo "Unsigned image blocked by Kyverno: PASS ✓"
# ── 5. Confirm signed image deploys successfully ──────────────────────────
kubectl run signed-pod --image=$ECR/ipl-scorecard:latest --dry-run=server
# Expected: pod/signed-pod created (dry run) — signature verified
echo "Signed image admitted by Kyverno: PASS ✓"
# ── 6. Review Kyverno policy report for any unexpected violations ─────────
kubectl get policyreport -A -o json | jq '.items[].results[] | select(.result != "pass") | {policy, resource, message}'
# Expected: empty output — no violations in the clusterStep 4 — Testing & Verification
Complete the lab verification checklist, which confirms every supply chain property independently. The checklist is designed so that each item produces an unambiguous pass or fail: the Cosign verify command exits 0 or non-zero, the Kyverno dry-run admission either succeeds or returns a Forbidden error, the SBOM component count is a non-zero integer. A lab that passes all checklist items has demonstrated the complete supply chain integrity chain from source commit to admission enforcement, ready for the M5 module's deeper treatment of Kubernetes RBAC and Pod Security Standards.
# Final verification checklist
echo "=== M2 Lab — Supply Chain Security Verification ==="
# 1. SBOM attached as OCI referrer
echo -n "1. SBOM attached to image: "
cosign tree "$ECR/ipl-scorecard:latest" 2>/dev/null | grep -q "cyclonedx" && echo "PASS ✓" || echo "FAIL ✗"
# 2. Cosign signature present and verifiable
echo -n "2. Cosign signature valid: "
cosign verify --certificate-identity-regexp "https://github.com/org/ipl-scorecard.*" --certificate-oidc-issuer "https://token.actions.githubusercontent.com" "$ECR/ipl-scorecard:latest" > /dev/null 2>&1 && echo "PASS ✓" || echo "FAIL ✗"
# 3. SLSA provenance attestation present and points to correct repo
echo -n "3. SLSA provenance attestation valid: "
cosign verify-attestation --certificate-identity-regexp "https://github.com/org/ipl-scorecard.*" --certificate-oidc-issuer "https://token.actions.githubusercontent.com" --type slsaprovenance "$ECR/ipl-scorecard:latest" 2>/dev/null | jq -e '.payload | @base64d | fromjson | .predicate.invocation.configSource.uri' | grep -q "github.com/org/ipl-scorecard" && echo "PASS ✓" || echo "FAIL ✗"
# 4. Vulnerability scan attestation present
echo -n "4. Vuln scan attestation attached: "
cosign tree "$ECR/ipl-scorecard:latest" 2>/dev/null | grep -q "vuln" && echo "PASS ✓" || echo "FAIL ✗"
# 5. Kyverno blocks unsigned image
echo -n "5. Kyverno blocks unsigned image: "
kubectl run test-unsigned --image="$ECR/ipl-scorecard:unsigned-test" --dry-run=server 2>&1 | grep -q "forbidden" && echo "PASS ✓" || echo "FAIL ✗"
# 6. Kyverno admits signed image
echo -n "6. Kyverno admits signed image: "
kubectl run test-signed --image="$ECR/ipl-scorecard:latest" --dry-run=server > /dev/null 2>&1 && echo "PASS ✓" || echo "FAIL ✗"
# 7. Zero Kyverno policy violations in cluster
echo -n "7. No unexpected policy violations: "
VIOLATIONS=$(kubectl get policyreport -A -o json 2>/dev/null | python3 -c "
import sys, json
data = json.load(sys.stdin)
n = sum(1 for item in data.get('items',[]) for r in item.get('results',[]) if r.get('result') != 'pass')
print(n)
")
[ "$VIOLATIONS" -eq "0" ] && echo "PASS ✓ (0 violations)" || echo "FAIL ✗ ($VIOLATIONS violations)"
echo "=== Lab complete — supply chain integrity chain verified ===" Warning: The `slsa-github-generator` reusable workflow must be called from a separate GitHub Actions job, not from a step within the existing build job. The generator requires isolated access to the GitHub Actions OIDC token to produce a Level 3 provenance attestation; running it from a shared job context would downgrade the attestation to Level 2 because the provenance could theoretically be influenced by other steps in the same job. The `needs:` key that makes the provenance job depend on the build job is not optional — the build job must complete successfully and output the image digest before the provenance generator can reference it. Attempting to inline the slsa-github-generator as a step will silently produce an invalid attestation that fails SLSA Level 3 verification.
Extension Challenge: Implement a Kubernetes `NetworkPolicy` that restricts the `ipl-scorecard` pods to only accept inbound traffic from Nginx pods (using pod label selectors) and only allow outbound traffic to the `ipl_postgres` and `ipl_redis` pods. This adds the Kubernetes-native equivalent of the Compose multi-network micro-segmentation from the M2 Exercise — the same security boundary, implemented at the CNI layer rather than the Docker bridge layer. Compare the Compose network declaration to the Kubernetes NetworkPolicy specification line by line as a preview of the M3 module's coverage of Kubernetes networking primitives.
- Implement supply chain controls incrementally — generate SBOM, add signing, add provenance, then enforce — verifying each layer independently before adding the next to avoid compound failures that obscure root causes.
- SBOM attachment as an OCI referrer co-locates the software inventory with the image in the registry, making it queryable without external infrastructure and ensuring it travels with the image through registry mirrors.
- The SLSA provenance generator must run in a separate GitHub Actions job with isolated OIDC token access to produce Level 3 provenance — inlining it as a step in the build job silently downgrades the attestation.
- Always deploy Kyverno policies in Audit mode first and monitor the policy report for violations before switching to Enforce — switching directly to Enforce on an unsigned deployment will immediately block all pods.
- The full supply chain trust chain — source commit → OIDC-bound signing → Rekor log entry → Kyverno admission verification — is end-to-end verifiable offline using Rekor's root of trust without contacting any external service.
- Use `cosign tree <image>` to inspect the complete OCI referrer graph for an image, confirming that signatures, SBOM attachments, and attestations are all present and correctly associated with the image digest.