CKS — Image Security Lab
Image Security — Hands-On Lab
CKS Exam Domain: Supply Chain Security (20%)
This lab walks you through the complete image security lifecycle — scanning for vulnerabilities, building minimal images, enforcing registry restrictions, signing images, and configuring ImagePolicyWebhook. Each exercise mirrors CKS exam patterns.
Prerequisites
- A Kubernetes cluster with control plane access (kubeadm preferred)
kubectlconfigured with cluster-admin accesstrivyinstalled (brew install trivyorapt install trivy)cosigninstalled (brew install cosign)dockerorpodmanavailable for building images- Access to a container registry (Docker Hub, GHCR, or a private registry)
Lab 1: Scan Images with Trivy
Task
Scan container images at different severity levels, identify vulnerable images in a running cluster, and take remediation action.
Steps
# Create a namespace for the lab
kubectl create namespace image-lab
# Deploy Pods with various images
kubectl run nginx-old --image=nginx:1.21 -n image-lab
kubectl run nginx-new --image=nginx:1.27 -n image-lab
kubectl run redis --image=redis:7.2 -n image-lab
kubectl run alpine --image=alpine:3.19 -n image-lab
# Wait for Pods to be running
kubectl get pods -n image-lab -w
Scan All Images
# Step 1: Get all unique images in the namespace
kubectl get pods -n image-lab -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{"\n"}{end}{end}' | sort -u
# Expected output:
# alpine:3.19
# nginx:1.21
# nginx:1.27
# redis:7.2
Scan with Different Severity Filters
# Full scan (all severities)
trivy image nginx:1.21
# CRITICAL only
trivy image --severity CRITICAL nginx:1.21
# CRITICAL and HIGH
trivy image --severity CRITICAL,HIGH nginx:1.21
# Quiet mode (summary only)
trivy image --severity CRITICAL --quiet nginx:1.21
Find and Delete Vulnerable Pods
# Scan each image and identify CRITICAL vulnerabilities
for img in $(kubectl get pods -n image-lab -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{"\n"}{end}{end}' | sort -u); do
echo "=== Scanning: $img ==="
if ! trivy image --severity CRITICAL --exit-code 1 --quiet "$img" 2>/dev/null; then
echo ">>> CRITICAL vulnerabilities found in: $img"
# Find Pods using this image
echo "Affected Pods:"
kubectl get pods -n image-lab -o json | \
jq -r --arg IMG "$img" '.items[] | select(.spec.containers[].image == $IMG) | .metadata.name'
fi
echo ""
done
Delete Pods with CRITICAL Vulnerabilities
# Example: If nginx:1.21 has CRITICAL CVEs, delete the Pod
kubectl delete pod nginx-old -n image-lab
# Verify
kubectl get pods -n image-lab
Expected Output
The older nginx:1.21 image will have significantly more CRITICAL vulnerabilities than nginx:1.27. The alpine:3.19 image will typically have zero or very few.
Lab 2: Build a Minimal Multi-Stage Image
Task
Take a Python application with a bloated Dockerfile and rebuild it using a multi-stage build with a distroless runtime image.
Step 1: Create the Application
mkdir -p /tmp/image-lab/app && cd /tmp/image-lab
# Create a simple Python app
cat > app/main.py <<'EOF'
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
response = {"status": "ok", "message": "Secure minimal image"}
self.wfile.write(json.dumps(response).encode())
if __name__ == "__main__":
server = HTTPServer(("0.0.0.0", 8080), Handler)
print("Server running on port 8080")
server.serve_forever()
EOF
Step 2: Create the Bloated Dockerfile (Before)
cat > Dockerfile.bloated <<'EOF'
FROM python:3.12
WORKDIR /app
COPY . /app
RUN pip install flask requests boto3
ENV SECRET_KEY=mysecretkey123
CMD ["python", "app/main.py"]
EOF
Step 3: Create the Minimal Dockerfile (After)
cat > Dockerfile.minimal <<'EOF'
# ──────────────────────────
# Stage 1: Build
# ──────────────────────────
FROM python:3.12-slim AS builder
WORKDIR /build
# No external dependencies needed for this app
# If needed: COPY requirements.txt . && pip install --prefix=/install -r requirements.txt
# ──────────────────────────
# Stage 2: Runtime
# ──────────────────────────
FROM gcr.io/distroless/python3-debian12
COPY app/main.py /app/main.py
WORKDIR /app
USER 65534
CMD ["main.py"]
EOF
Step 4: Build and Compare
# Build both images
docker build -f Dockerfile.bloated -t myapp:bloated .
docker build -f Dockerfile.minimal -t myapp:minimal .
# Compare sizes
docker images | grep myapp
Expected Output
REPOSITORY TAG IMAGE ID SIZE
myapp bloated a1b2c3d4e5f6 1.02GB
myapp minimal f6e5d4c3b2a1 52.8MB
Step 5: Scan Both Images
echo "=== Bloated Image ==="
trivy image --severity CRITICAL,HIGH myapp:bloated
echo ""
echo "=== Minimal Image ==="
trivy image --severity CRITICAL,HIGH myapp:minimal
The bloated image will have significantly more vulnerabilities due to the full Debian OS, pip packages, and development tools.
Step 6: Test Shell Access
# Bloated image — shell available (security risk)
docker run --rm -it myapp:bloated /bin/bash
# root@container:/app# whoami → root
# root@container:/app# apt list --installed | wc -l → hundreds of packages
# Minimal image — no shell (secure)
docker run --rm -it myapp:minimal /bin/sh
# docker: Error response from daemon: OCI runtime create failed:
# ... executable file not found in $PATH
Lab 3: OPA Gatekeeper — Restrict Image Registries
Task
Install OPA Gatekeeper and create a policy that allows only images from docker.io/library/ (official Docker images) and a hypothetical private registry.
Step 1: Install Gatekeeper
kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper/v3.15.0/deploy/gatekeeper.yaml
# Wait for Gatekeeper to be ready
kubectl wait --for=condition=Ready pods -l control-plane=controller-manager -n gatekeeper-system --timeout=120s
Step 2: Create ConstraintTemplate
cat <<'EOF' | kubectl apply -f -
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8sallowedrepos
spec:
crd:
spec:
names:
kind: K8sAllowedRepos
validation:
openAPIV3Schema:
type: object
properties:
repos:
type: array
items:
type: string
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8sallowedrepos
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
not startswith(container.image, input.parameters.repos[_])
msg := sprintf("Container '%v' uses image '%v' not from allowed registries: %v",
[container.name, container.image, input.parameters.repos])
}
violation[{"msg": msg}] {
container := input.review.object.spec.initContainers[_]
not startswith(container.image, input.parameters.repos[_])
msg := sprintf("Init container '%v' uses image '%v' not from allowed registries: %v",
[container.name, container.image, input.parameters.repos])
}
EOF
Step 3: Create Constraint
cat <<'EOF' | kubectl apply -f -
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sAllowedRepos
metadata:
name: allowed-repos-lab
spec:
enforcementAction: deny
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
namespaces:
- image-lab
parameters:
repos:
- "docker.io/library/"
- "registry.company.com/"
- "nginx"
- "redis"
- "alpine"
EOF
Note: Images without a registry prefix (like
nginx:1.27) resolve todocker.io/library/nginx:1.27. Gatekeeper matches on the literal image string in the Pod spec, so we also include the short form.
Step 4: Test the Policy
# Should SUCCEED — official image
kubectl run test-allowed --image=nginx:1.27 -n image-lab
kubectl delete pod test-allowed -n image-lab
# Should be DENIED — untrusted registry
kubectl run test-denied --image=registry.evil.com/malware:latest -n image-lab
Expected Output for Denied Request
Error from server (Forbidden): admission webhook "validation.gatekeeper.sh" denied the request:
[allowed-repos-lab] Container 'test-denied' uses image 'registry.evil.com/malware:latest'
not from allowed registries: ["docker.io/library/", "registry.company.com/", "nginx", "redis", "alpine"]
Step 5: Check Gatekeeper Audit
# View constraint violations (audit mode finds existing violations)
kubectl get k8sallowedrepos allowed-repos-lab -o yaml | grep -A20 status
Lab 4: Test Pod Rejection from Untrusted Registry
Task
Deploy Pods from various registries and verify that only trusted sources are allowed.
Steps
# Test matrix — verify each one is allowed or denied
# ✅ Should be allowed (official nginx)
kubectl run t1 --image=nginx:1.27 -n image-lab && kubectl delete pod t1 -n image-lab
echo "Test 1: PASSED (allowed)"
# ❌ Should be denied (untrusted registry)
kubectl run t2 --image=gcr.io/random-project/app:v1 -n image-lab 2>&1 | grep -q "denied" && \
echo "Test 2: PASSED (denied)" || echo "Test 2: FAILED"
# ❌ Should be denied (Docker Hub non-official image)
kubectl run t3 --image=someuser/someimage:latest -n image-lab 2>&1 | grep -q "denied" && \
echo "Test 3: PASSED (denied)" || echo "Test 3: FAILED"
# ✅ Should be allowed (redis - in our allowlist)
kubectl run t4 --image=redis:7.2 -n image-lab && kubectl delete pod t4 -n image-lab
echo "Test 4: PASSED (allowed)"
Lab 5: Sign and Verify an Image with Cosign
Task
Generate a key pair, sign a container image, verify the signature, and demonstrate failed verification on an unsigned image.
Step 1: Generate Keys
cd /tmp/image-lab
# Generate key pair
cosign generate-key-pair
# Enter a password when prompted
# Creates: cosign.key, cosign.pub
ls -la cosign.*
Step 2: Tag and Push an Image
# Tag the minimal image for a registry you can push to
# (Replace with your actual registry)
docker tag myapp:minimal docker.io/<your-username>/myapp:v1.0-signed
docker push docker.io/<your-username>/myapp:v1.0-signed
Step 3: Sign the Image
cosign sign --key cosign.key docker.io/<your-username>/myapp:v1.0-signed
# Enter the password for cosign.key
# Cosign stores the signature as a separate tag in the registry
Step 4: Verify the Signature
# Verify with the public key — should SUCCEED
cosign verify --key cosign.pub docker.io/<your-username>/myapp:v1.0-signed
# Output:
# Verification for docker.io/<your-username>/myapp:v1.0-signed --
# The following checks were performed on each of these signatures:
# - The cosign claims were validated
# - The signatures were verified against the specified public key
Step 5: Verify an Unsigned Image — Should FAIL
# Try to verify an image that was never signed
cosign verify --key cosign.pub nginx:1.27
# Output:
# Error: no matching signatures:
# ...
Step 6: View Signature Details
# Inspect the signature payload
cosign verify --key cosign.pub docker.io/<your-username>/myapp:v1.0-signed | jq .
# Show the signature in the registry
cosign triangulate docker.io/<your-username>/myapp:v1.0-signed
# Shows the tag where the signature is stored: sha256-<digest>.sig
Lab 6: Configure ImagePolicyWebhook (Conceptual Walkthrough)
Task
Walk through the complete configuration of ImagePolicyWebhook on a kubeadm cluster. This is a conceptual exercise — on a real cluster, you would execute each command on the control plane node.
Step 1: Understand the File Structure
/etc/kubernetes/admission/
├── admission-config.yaml ← AdmissionConfiguration
├── webhook-kubeconfig.yaml ← Backend connection details
└── certs/
├── ca.crt ← CA certificate
├── ca.key ← CA private key
├── server.crt ← Backend server certificate
├── server.key ← Backend server key
├── apiserver-client.crt ← API server client certificate
└── apiserver-client.key ← API server client key
Step 2: Create AdmissionConfiguration
# /etc/kubernetes/admission/admission-config.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
- name: ImagePolicyWebhook
configuration:
imagePolicy:
kubeConfigFile: /etc/kubernetes/admission/webhook-kubeconfig.yaml
allowTTL: 50
denyTTL: 50
retryBackoff: 500
defaultAllow: false # Fail-closed — secure default
Step 3: Create Webhook Kubeconfig
# /etc/kubernetes/admission/webhook-kubeconfig.yaml
apiVersion: v1
kind: Config
clusters:
- name: image-policy-backend
cluster:
server: https://image-policy-backend.default.svc:8443/image-policy
certificate-authority: /etc/kubernetes/admission/certs/ca.crt
users:
- name: kube-apiserver
user:
client-certificate: /etc/kubernetes/admission/certs/apiserver-client.crt
client-key: /etc/kubernetes/admission/certs/apiserver-client.key
contexts:
- name: default
context:
cluster: image-policy-backend
user: kube-apiserver
current-context: default
Step 4: Modify API Server Manifest
The following flags and volume mounts need to be added to /etc/kubernetes/manifests/kube-apiserver.yaml:
# Command additions:
- --enable-admission-plugins=NodeRestriction,ImagePolicyWebhook
- --admission-control-config-file=/etc/kubernetes/admission/admission-config.yaml
# Volume mount addition:
volumeMounts:
- name: admission-config
mountPath: /etc/kubernetes/admission
readOnly: true
# Volume addition:
volumes:
- name: admission-config
hostPath:
path: /etc/kubernetes/admission
type: DirectoryOrCreate
Step 5: Verify Configuration
# After the API server restarts, test:
# With defaultAllow: false and no backend, all Pods are denied
kubectl run test --image=nginx:1.27
# Expected: Error from server (Forbidden): ...
# Check API server logs for configuration status
sudo crictl logs $(sudo crictl ps --name kube-apiserver -q) 2>&1 | grep -i "imagepolicy"
Key Takeaways
- Three files are needed: AdmissionConfiguration, webhook kubeconfig, TLS certs
- Two API server flags:
--enable-admission-pluginsand--admission-control-config-file - Volume mounts are required to make config available inside the static Pod
defaultAllow: falseis the secure default (fail-closed)- The API server restarts automatically after manifest changes — wait for it
Cleanup
# Delete the lab namespace
kubectl delete namespace image-lab
# Remove Gatekeeper (if you want to clean up)
kubectl delete -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper/v3.15.0/deploy/gatekeeper.yaml
# Remove Gatekeeper CRDs
kubectl delete constrainttemplate k8sallowedrepos
# Clean up local files
rm -rf /tmp/image-lab
Practice Questions
Question 1
You are given a namespace critical-apps and asked to find all images with CRITICAL vulnerabilities and delete the affected Pods. A new image registry.company.com/api:v4.2 is provided as the replacement. Write the complete sequence of commands.
Show Answer
# 1. List all images in the namespace
kubectl get pods -n critical-apps -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{range .spec.containers[*]}{.image}{"\n"}{end}{end}'
# 2. Scan each unique image
for img in $(kubectl get pods -n critical-apps -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{"\n"}{end}{end}' | sort -u); do
echo "=== $img ==="
trivy image --severity CRITICAL --quiet "$img"
done
# 3. Delete Pods running images with CRITICAL CVEs
for img in $(kubectl get pods -n critical-apps -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{"\n"}{end}{end}' | sort -u); do
if ! trivy image --severity CRITICAL --exit-code 1 --quiet "$img" 2>/dev/null; then
echo "Deleting Pods with image: $img"
kubectl get pods -n critical-apps -o json | \
jq -r --arg IMG "$img" '.items[] | select(.spec.containers[].image == $IMG) | .metadata.name' | \
xargs kubectl delete pod -n critical-apps
fi
done
# 4. If a Deployment controls these Pods, update the image
kubectl set image deployment/<deployment-name> <container-name>=registry.company.com/api:v4.2 -n critical-apps
# 5. Verify the rollout
kubectl rollout status deployment/<deployment-name> -n critical-apps
# 6. Scan the replacement image
trivy image --severity CRITICAL registry.company.com/api:v4.2
Question 2
Convert the following Dockerfile into a secure multi-stage build:
FROM ubuntu:22.04
RUN apt update && apt install -y golang-go
COPY . /app
WORKDIR /app
RUN go build -o server .
CMD ["./server"]
Show Answer
# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server .
# Stage 2: Runtime
FROM gcr.io/distroless/static-debian12
COPY --from=builder /build/server /server
USER 65534
ENTRYPOINT ["/server"]
Improvements:
- Multi-stage build — Go compiler and source not in final image
CGO_ENABLED=0— Static binary, no C dependencies-ldflags="-s -w"— Strips debug info, smaller binarydistroless/static— No shell, no package manager (~2 MB base)USER 65534— Runs as non-root (nobody)- Final image: ~10 MB vs ~800 MB+ with ubuntu+golang
Question 3
Write a Gatekeeper Constraint that denies Pods in the production namespace if they use the latest tag.
Show Answer
First, the ConstraintTemplate:
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8sdisallowedtags
spec:
crd:
spec:
names:
kind: K8sDisallowedTags
validation:
openAPIV3Schema:
type: object
properties:
tags:
type: array
items:
type: string
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8sdisallowedtags
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
tag := split(container.image, ":")[1]
tag == input.parameters.tags[_]
msg := sprintf("Container '%v' uses disallowed tag '%v' in image '%v'",
[container.name, tag, container.image])
}
violation[{"msg": msg}] {
container := input.review.object.spec.containers[_]
not contains(container.image, ":")
not contains(container.image, "@")
msg := sprintf("Container '%v' image '%v' has no tag (defaults to 'latest')",
[container.name, container.image])
}
Then the Constraint:
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sDisallowedTags
metadata:
name: no-latest-tag
spec:
enforcementAction: deny
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
namespaces:
- production
parameters:
tags:
- "latest"
Question 4
During the CKS exam, you are asked to configure ImagePolicyWebhook with defaultAllow: false. The backend webhook URL is https://image-checker.default.svc:1323/image_policy. The CA, client cert, and client key are at /etc/kubernetes/admission/certs/. Write the AdmissionConfiguration and webhook kubeconfig.
Show Answer
AdmissionConfiguration (/etc/kubernetes/admission/admission-config.yaml):
apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
- name: ImagePolicyWebhook
configuration:
imagePolicy:
kubeConfigFile: /etc/kubernetes/admission/webhook-kubeconfig.yaml
allowTTL: 50
denyTTL: 50
retryBackoff: 500
defaultAllow: false
Webhook kubeconfig (/etc/kubernetes/admission/webhook-kubeconfig.yaml):
apiVersion: v1
kind: Config
clusters:
- name: image-checker
cluster:
server: https://image-checker.default.svc:1323/image_policy
certificate-authority: /etc/kubernetes/admission/certs/ca.crt
users:
- name: api-server
user:
client-certificate: /etc/kubernetes/admission/certs/apiserver-client.crt
client-key: /etc/kubernetes/admission/certs/apiserver-client.key
contexts:
- name: default
context:
cluster: image-checker
user: api-server
current-context: default
API server flags:
--enable-admission-plugins=NodeRestriction,ImagePolicyWebhook
--admission-control-config-file=/etc/kubernetes/admission/admission-config.yaml
Question 5
What is the complete defense-in-depth strategy for image security in Kubernetes? List each layer in order.
Show Answer
| Order | Layer | Action | Tool |
|---|---|---|---|
| 1 | Build | Use minimal base images (distroless, Alpine), multi-stage builds, non-root user, no secrets in layers | Dockerfile best practices |
| 2 | Scan | Scan for CVEs before pushing to registry, fail CI on CRITICAL | Trivy, Grype |
| 3 | Sign | Sign images after build, create provenance attestations | Cosign, Sigstore |
| 4 | Store | Use private registries, enable vulnerability scanning at registry level | Harbor, ECR, GCR |
| 5 | Admit | Enforce registry restrictions, require signatures, validate policies | OPA Gatekeeper, Kyverno, ImagePolicyWebhook |
| 6 | Deploy | Pin images by digest, use imagePullPolicy: Always, set imagePullSecrets |
Kubernetes Pod spec |
| 7 | Runtime | Monitor for anomalous behavior, detect drift from original image | Falco, runtime scanners |
| 8 | Continuously Scan | Re-scan running images as new CVEs are disclosed | Trivy operator, scheduled scans |
No single control is sufficient. Each layer catches what the previous one missed.