Skip to content

Solutions -- Domain 3: Minimize Microservice Vulnerabilities

Study Approach

Work through each question BEFORE reading the solution. These solutions include detailed explanations of why each step is necessary, not just the commands to run.


Solution 1 -- SecurityContext: Non-Root Enforcement

Concepts tested: runAsUser, runAsGroup, runAsNonRoot, allowPrivilegeEscalation, capabilities

First, get the current pod spec, then delete and recreate with the security context:

bash
# Export current pod spec
kubectl get pod legacy-app -n production -o yaml > legacy-app.yaml

Edit the pod spec to add the security context:

yaml
apiVersion: v1
kind: Pod
metadata:
  name: legacy-app
  namespace: production
spec:
  securityContext:
    runAsUser: 1000
    runAsGroup: 1000
    runAsNonRoot: true
  containers:
  - name: app
    image: nginx:1.25
    securityContext:
      allowPrivilegeEscalation: false
      capabilities:
        drop:
        - ALL
bash
# Delete and recreate
kubectl delete pod legacy-app -n production
kubectl apply -f legacy-app.yaml

# Verify
kubectl exec -n production legacy-app -- id
# uid=1000 gid=1000 groups=1000

Why This Works

  • runAsUser: 1000 forces the container to run as UID 1000 instead of root
  • runAsNonRoot: true is a safety net -- Kubernetes rejects the pod if it somehow tries to run as root
  • allowPrivilegeEscalation: false sets the no_new_privs flag, preventing SUID exploits
  • drop: ["ALL"] removes all Linux capabilities, minimizing the attack surface

Exam Speed Tip

You can set runAsUser, runAsGroup, and runAsNonRoot at the pod level to apply to all containers. Set allowPrivilegeEscalation and capabilities at the container level since they are container-only settings.


Solution 2 -- SecurityContext: Read-Only Filesystem

Concepts tested: readOnlyRootFilesystem, emptyDir volumes, seccompProfile

yaml
apiVersion: v1
kind: Pod
metadata:
  name: secure-nginx
  namespace: default
spec:
  securityContext:
    runAsUser: 101
    runAsGroup: 101
    seccompProfile:
      type: RuntimeDefault
  containers:
  - name: nginx
    image: nginx:1.25
    securityContext:
      readOnlyRootFilesystem: true
    ports:
    - containerPort: 80
    volumeMounts:
    - name: cache
      mountPath: /var/cache/nginx
    - name: run
      mountPath: /var/run
    - name: tmp
      mountPath: /tmp
  volumes:
  - name: cache
    emptyDir: {}
  - name: run
    emptyDir: {}
  - name: tmp
    emptyDir: {}
bash
kubectl apply -f secure-nginx.yaml

# Verify read-only filesystem
kubectl exec secure-nginx -- touch /test-file 2>&1
# touch: /test-file: Read-only file system

# Verify writable paths work
kubectl exec secure-nginx -- touch /tmp/test-file
# Success (no error)

# Verify nginx is running
kubectl exec secure-nginx -- curl -s localhost:80 | head -5

Why These Specific Paths?

Nginx needs to write to:

  • /var/cache/nginx -- Proxy cache and temp files
  • /var/run -- PID file (nginx.pid)
  • /tmp -- Temporary upload files

Without these writable mounts, nginx fails to start with "Read-only file system" errors.


Solution 3 -- SecurityContext: Pod vs Container Precedence

Concepts tested: SecurityContext inheritance, override behavior, fsGroup

yaml
apiVersion: v1
kind: Pod
metadata:
  name: multi-user-pod
  namespace: default
spec:
  securityContext:
    runAsUser: 1000
    runAsGroup: 3000
    fsGroup: 2000
  containers:
  - name: writer
    image: busybox:1.36
    command: ["sh", "-c", "id > /data/user.txt && sleep 3600"]
    securityContext:
      runAsUser: 2000              # Overrides pod-level 1000
    volumeMounts:
    - name: shared
      mountPath: /data
  - name: reader
    image: busybox:1.36
    command: ["sh", "-c", "sleep 3600"]
    volumeMounts:                  # Inherits pod-level: UID 1000
    - name: shared
      mountPath: /data
  volumes:
  - name: shared
    emptyDir: {}
bash
kubectl apply -f multi-user-pod.yaml

# Verify writer runs as UID 2000 (overridden)
kubectl exec multi-user-pod -c writer -- id
# uid=2000 gid=3000 groups=2000,3000

# Verify reader runs as UID 1000 (inherited from pod)
kubectl exec multi-user-pod -c reader -- id
# uid=1000 gid=3000 groups=2000,3000

# Check file ownership -- created by writer (UID 2000), fsGroup 2000
kubectl exec multi-user-pod -c reader -- ls -la /data/user.txt
# -rw-r--r-- 1 2000 2000 ... user.txt

# Check content
kubectl exec multi-user-pod -c reader -- cat /data/user.txt
# uid=2000 gid=3000 groups=2000,3000

Why This Matters

Container-level runAsUser: 2000 overrides the pod-level runAsUser: 1000 for the writer container. The reader container inherits the pod-level value. The fsGroup: 2000 is always pod-level and applies to all containers, making volume files accessible to both containers via the supplemental group.


Solution 4 -- Secrets: Encryption at Rest

Concepts tested: EncryptionConfiguration, aescbc, API server configuration, etcd verification

Step 1: Generate the encryption key

bash
# Generate a 32-byte random key, base64-encoded
head -c 32 /dev/urandom | base64
# Example output: aGVsbG93b3JsZHRoaXNpc2EzMmJ5dGVrZXlmb3J0ZXN0

Step 2: Create the EncryptionConfiguration

bash
sudo mkdir -p /etc/kubernetes/enc
yaml
# /etc/kubernetes/enc/encryption-config.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
  - resources:
    - secrets
    providers:
    - aescbc:
        keys:
        - name: key1
          secret: aGVsbG93b3JsZHRoaXNpc2EzMmJ5dGVrZXlmb3J0ZXN0
    - identity: {}
bash
sudo vi /etc/kubernetes/enc/encryption-config.yaml
# (paste the above content)

Step 3: Configure the API server

bash
sudo vi /etc/kubernetes/manifests/kube-apiserver.yaml

Add the following to the kube-apiserver command:

yaml
spec:
  containers:
  - command:
    - kube-apiserver
    - --encryption-provider-config=/etc/kubernetes/enc/encryption-config.yaml
    # ... keep existing flags ...
    volumeMounts:
    # ... keep existing mounts ...
    - name: enc
      mountPath: /etc/kubernetes/enc
      readOnly: true
  volumes:
  # ... keep existing volumes ...
  - name: enc
    hostPath:
      path: /etc/kubernetes/enc
      type: DirectoryOrCreate

Step 4: Wait for API server restart

bash
# Watch for the API server to restart
watch crictl ps | grep kube-apiserver
# Or keep trying:
kubectl get nodes

Step 5: Create a test secret

bash
kubectl create secret generic encrypted-secret \
  --from-literal=password=SuperSecret123

Step 6: Verify encryption in etcd

bash
ETCDCTL_API=3 etcdctl get /registry/secrets/default/encrypted-secret \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key \
  | hexdump -C | head -20

# Look for "k8s:enc:aescbc:v1:key1:" prefix
# If you see plain "SuperSecret123" text, encryption is NOT working

Critical Detail

The identity: {} provider MUST be listed last. It serves as a fallback to read old unencrypted secrets but should never be used for writing new ones. If you accidentally put it first, secrets will be stored unencrypted.

Exam Speed Tip

Keep the encryption key generation command (head -c 32 /dev/urandom | base64) memorized. The etcdctl command with certificate paths is long -- consider creating an alias at the start of the exam.


Solution 5 -- Secrets: Environment Variables to Volume Mounts

Concepts tested: Secret volume mounts, file permissions, security implications

bash
# First, ensure the secret exists
kubectl get secret db-credentials -n security

Create the refactored pod:

yaml
apiVersion: v1
kind: Pod
metadata:
  name: insecure-app
  namespace: security
spec:
  containers:
  - name: app
    image: nginx:1.25       # Use the same image as original
    # REMOVED: env/envFrom with secretKeyRef
    volumeMounts:
    - name: db-secrets
      mountPath: /etc/secrets
      readOnly: true
  volumes:
  - name: db-secrets
    secret:
      secretName: db-credentials
      defaultMode: 0400          # Read-only for owner
bash
# Delete old pod and apply
kubectl delete pod insecure-app -n security
kubectl apply -f refactored-app.yaml

# Verify secrets are mounted as files
kubectl exec -n security insecure-app -- ls -la /etc/secrets/
# -r-------- 1 root root ... password
# -r-------- 1 root root ... username

# Verify content is readable
kubectl exec -n security insecure-app -- cat /etc/secrets/password

# Verify no environment variables
kubectl exec -n security insecure-app -- env | grep DB_
# (no output)

Why Volume Mounts Are More Secure

  • Secrets as env vars are visible via /proc/*/environ, inherited by child processes, and can leak in logs/crash dumps
  • Volume-mounted secrets are only accessible at the mount path
  • defaultMode: 0400 restricts access to the file owner only
  • readOnly: true prevents accidental modification
  • Volume-mounted secrets are automatically updated when the secret changes (env vars require pod restart)

Solution 6 -- Secrets: Verify etcd Encryption

Concepts tested: Encryption verification, etcdctl, API server flags

Step 1: Check API server flag

bash
# Check if encryption-provider-config is set
ps aux | grep kube-apiserver | grep encryption-provider-config

# Or check the manifest
cat /etc/kubernetes/manifests/kube-apiserver.yaml | grep encryption-provider-config
# Expected: --encryption-provider-config=/etc/kubernetes/enc/encryption-config.yaml

Step 2: Inspect the EncryptionConfiguration

bash
sudo cat /etc/kubernetes/enc/encryption-config.yaml
# Look for the provider type (aescbc, secretbox, aesgcm, identity)
# Verify identity is NOT the first provider

Step 3: Create a test secret

bash
kubectl create secret generic enc-test --from-literal=testkey=verify-me

Step 4: Read directly from etcd

bash
ETCDCTL_API=3 etcdctl get /registry/secrets/default/enc-test \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key

# ENCRYPTED output looks like: k8s:enc:aescbc:v1:key1:<binary data>
# UNENCRYPTED output would show readable text including "verify-me"

Step 5: Confirm kubectl access still works

bash
kubectl get secret enc-test -o jsonpath='{.data.testkey}' | base64 -d
# verify-me

Verification Pattern

If the etcd output starts with k8s:enc:<provider>:, encryption is working. If you can read the plain text value, it is NOT encrypted. The API server transparently decrypts when you use kubectl, so kubectl get secret always shows the decrypted value.


Solution 7 -- OPA/Gatekeeper: Require Labels

Concepts tested: ConstraintTemplate, Constraint, Rego, match configuration

ConstraintTemplate

yaml
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8srequiredlabels
spec:
  crd:
    spec:
      names:
        kind: K8sRequiredLabels
      validation:
        openAPIV3Schema:
          type: object
          properties:
            labels:
              type: array
              items:
                type: string
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8srequiredlabels

        violation[{"msg": msg}] {
          provided := {label | input.review.object.metadata.labels[label]}
          required := {label | label := input.parameters.labels[_]}
          missing := required - provided
          count(missing) > 0
          msg := sprintf("Missing required labels: %v", [missing])
        }

Constraint

yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredLabels
metadata:
  name: require-team-label
spec:
  enforcementAction: deny
  match:
    kinds:
    - apiGroups: [""]
      kinds: ["Pod"]
    namespaces:
    - production
    excludedNamespaces:
    - kube-system
  parameters:
    labels:
    - "team"
bash
# Apply both
kubectl apply -f constrainttemplate.yaml
# Wait for CRD creation
sleep 5
kubectl apply -f constraint.yaml

# Verify template is ready
kubectl get constrainttemplate k8srequiredlabels

# Test: This should FAIL
kubectl run test-pod --image=nginx -n production --dry-run=server
# Error: Missing required labels: {"team"}

# Test: This should SUCCEED
kubectl run test-pod --image=nginx -n production --labels="team=backend" --dry-run=server
# pod/test-pod created (server dry run)

Rego Explanation

The Rego policy uses set operations:

  1. provided -- set of labels the object already has
  2. required -- set of labels from the constraint parameters
  3. missing -- set difference (required - provided)
  4. If missing has any elements, it is a violation

Solution 8 -- OPA/Gatekeeper: Deny Privileged Containers

Concepts tested: Rego for container iteration, init containers, excluded namespaces

ConstraintTemplate

yaml
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8sdenyprivileged
spec:
  crd:
    spec:
      names:
        kind: K8sDenyPrivileged
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8sdenyprivileged

        violation[{"msg": msg}] {
          container := input.review.object.spec.containers[_]
          container.securityContext.privileged == true
          msg := sprintf("Privileged container not allowed: %v", [container.name])
        }

        violation[{"msg": msg}] {
          container := input.review.object.spec.initContainers[_]
          container.securityContext.privileged == true
          msg := sprintf("Privileged init container not allowed: %v", [container.name])
        }

Constraint

yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sDenyPrivileged
metadata:
  name: no-privileged-pods
spec:
  enforcementAction: deny
  match:
    kinds:
    - apiGroups: [""]
      kinds: ["Pod"]
    excludedNamespaces:
    - kube-system
    - gatekeeper-system
bash
kubectl apply -f template-privileged.yaml
sleep 5
kubectl apply -f constraint-privileged.yaml

# Test: This should FAIL
cat <<EOF | kubectl apply --dry-run=server -f -
apiVersion: v1
kind: Pod
metadata:
  name: priv-test
spec:
  containers:
  - name: app
    image: nginx
    securityContext:
      privileged: true
EOF
# Error: Privileged container not allowed: app

# Test: This should SUCCEED
kubectl run safe-pod --image=nginx --dry-run=server
# pod/safe-pod created (server dry run)

Why Two Violation Rules?

We need separate rules for containers and initContainers because Rego iterates over them separately. The [_] syntax iterates over all elements in the array. Both regular and init containers can be privileged, so both must be checked.


Solution 9 -- OPA/Gatekeeper: Enforce Image Registry

Concepts tested: Parameterized policies, string operations in Rego, dryrun mode

ConstraintTemplate

yaml
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 image_allowed(container.image)
          msg := sprintf(
            "Container '%v' uses image '%v' which is not from an allowed repository. Allowed: %v",
            [container.name, container.image, input.parameters.repos]
          )
        }

        violation[{"msg": msg}] {
          container := input.review.object.spec.initContainers[_]
          not image_allowed(container.image)
          msg := sprintf(
            "Init container '%v' uses image '%v' which is not from an allowed repository. Allowed: %v",
            [container.name, container.image, input.parameters.repos]
          )
        }

        image_allowed(image) {
          repo := input.parameters.repos[_]
          startswith(image, repo)
        }

Constraint (start with dryrun)

yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sAllowedRepos
metadata:
  name: restrict-image-repos
spec:
  enforcementAction: dryrun        # Start with audit
  match:
    kinds:
    - apiGroups: [""]
      kinds: ["Pod"]
    namespaces:
    - production
  parameters:
    repos:
    - "docker.io/library/"
    - "gcr.io/my-company/"
bash
kubectl apply -f template-repos.yaml
sleep 5
kubectl apply -f constraint-repos.yaml

# Test in dryrun mode (will allow but record violation)
kubectl run bad-image --image=evil.io/hacker/tool -n production

# Check audit results
kubectl get k8sallowedrepos restrict-image-repos -o yaml | grep -A10 violations

# Switch to deny mode
kubectl patch k8sallowedrepos restrict-image-repos \
  --type='json' -p='[{"op":"replace","path":"/spec/enforcementAction","value":"deny"}]'

# Test again (should now be denied)
kubectl run bad-image2 --image=evil.io/hacker/tool -n production --dry-run=server
# Error: not from an allowed repository

Solution 10 -- OPA/Gatekeeper: Audit Mode

Concepts tested: enforcementAction, audit results, constraint status

bash
# Step 1: Change enforcement to dryrun
kubectl patch <constraint-kind> require-resource-limits \
  --type='json' -p='[{"op":"replace","path":"/spec/enforcementAction","value":"dryrun"}]'

# Or edit directly
kubectl edit <constraint-kind> require-resource-limits
# Change: enforcementAction: deny -> dryrun

# Step 2: Verify the change
kubectl get <constraint-kind> require-resource-limits -o jsonpath='{.spec.enforcementAction}'
# dryrun

# Step 3: Check audit results (wait a minute for audit to run)
kubectl get <constraint-kind> require-resource-limits -o yaml

# The violations appear in .status.violations:
kubectl get <constraint-kind> require-resource-limits \
  -o jsonpath='{range .status.violations[*]}{.namespace}/{.name}: {.message}{"\n"}{end}'

# Step 4: Document violating pods
kubectl get <constraint-kind> require-resource-limits \
  -o jsonpath='{range .status.violations[*]}Namespace: {.namespace}, Pod: {.name}{"\n"}{end}'

Why Audit Mode?

Switching from deny to dryrun allows existing workloads to continue running while you collect data about violations. The Gatekeeper audit controller periodically scans existing resources and reports violations in the constraint's .status.violations field. This gives teams time to fix their pods without breaking production.


Solution 11 -- RuntimeClass: gVisor Configuration

Concepts tested: RuntimeClass resource, runtimeClassName in pod spec

RuntimeClass

yaml
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc

Pod

yaml
apiVersion: v1
kind: Pod
metadata:
  name: sandboxed-app
  namespace: default
spec:
  runtimeClassName: gvisor
  containers:
  - name: web
    image: nginx:1.25
    ports:
    - containerPort: 80
bash
kubectl apply -f runtimeclass-gvisor.yaml
kubectl apply -f sandboxed-app.yaml

# Verify
kubectl get runtimeclass
# NAME     HANDLER   AGE
# gvisor   runsc     5s

kubectl get pod sandboxed-app -o jsonpath='{.spec.runtimeClassName}'
# gvisor

kubectl get pod sandboxed-app
# Verify it's Running (requires gVisor installed on the node)

Node Requirement

The pod will only schedule and run if the node has gVisor (runsc) installed and containerd is configured with the runsc handler. On the CKS exam, this is pre-configured. In a practice environment, the pod may stay in ContainerCreating if gVisor is not installed.


Solution 12 -- RuntimeClass: Multiple Runtimes

Concepts tested: RuntimeClass overhead, multiple runtime configurations

yaml
# gVisor RuntimeClass
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc
---
# Kata RuntimeClass with overhead
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: kata
handler: kata-runtime
overhead:
  podFixed:
    memory: "160Mi"
    cpu: "250m"
---
# gVisor pod
apiVersion: v1
kind: Pod
metadata:
  name: gvisor-pod
spec:
  runtimeClassName: gvisor
  containers:
  - name: app
    image: busybox:1.36
    command: ["sleep", "3600"]
---
# Kata pod
apiVersion: v1
kind: Pod
metadata:
  name: kata-pod
spec:
  runtimeClassName: kata
  containers:
  - name: app
    image: busybox:1.36
    command: ["sleep", "3600"]
bash
kubectl apply -f multi-runtime.yaml

# Verify RuntimeClasses
kubectl get runtimeclass
# NAME     HANDLER        AGE
# gvisor   runsc          5s
# kata     kata-runtime   5s

# Verify pod assignments
kubectl get pod gvisor-pod -o jsonpath='{.spec.runtimeClassName}'
# gvisor
kubectl get pod kata-pod -o jsonpath='{.spec.runtimeClassName}'
# kata

# Check overhead is applied to kata pod
kubectl get pod kata-pod -o jsonpath='{.spec.overhead}'
# {"cpu":"250m","memory":"160Mi"}

Solution 13 -- Admission Controllers: Enable ImagePolicyWebhook

Concepts tested: AdmissionConfiguration, kubeconfig for webhooks, API server flags

Step 1: Create admission configuration

yaml
# /etc/kubernetes/admission/admission-config.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
- name: ImagePolicyWebhook
  configuration:
    imagePolicy:
      kubeConfigFile: /etc/kubernetes/admission/kubeconfig.yaml
      allowTTL: 50
      denyTTL: 50
      retryBackoff: 500
      defaultAllow: false

Step 2: Create the kubeconfig

yaml
# /etc/kubernetes/admission/kubeconfig.yaml
apiVersion: v1
kind: Config
clusters:
- name: image-bouncer
  cluster:
    server: https://image-bouncer.default.svc:1323/image_policy
    certificate-authority: /etc/kubernetes/admission/ca.crt
users:
- name: api-server
  user:
    client-certificate: /etc/kubernetes/admission/api-server-client.crt
    client-key: /etc/kubernetes/admission/api-server-client.key
current-context: image-bouncer
contexts:
- context:
    cluster: image-bouncer
    user: api-server
  name: image-bouncer

Step 3: Configure the API server

bash
sudo vi /etc/kubernetes/manifests/kube-apiserver.yaml

Add/modify these sections:

yaml
spec:
  containers:
  - command:
    - kube-apiserver
    - --enable-admission-plugins=NodeRestriction,ImagePolicyWebhook
    - --admission-control-config-file=/etc/kubernetes/admission/admission-config.yaml
    # ... keep other flags ...
    volumeMounts:
    # ... keep existing mounts ...
    - name: admission
      mountPath: /etc/kubernetes/admission
      readOnly: true
  volumes:
  # ... keep existing volumes ...
  - name: admission
    hostPath:
      path: /etc/kubernetes/admission
      type: DirectoryOrCreate

Step 4: Verify

bash
# Wait for API server restart
watch crictl ps | grep kube-apiserver

# Test
kubectl get nodes  # Should work when API server is back

# If webhook service is not running and defaultAllow=false,
# pod creation will be denied
kubectl run test --image=nginx
# Error: images "nginx" denied by ImagePolicyWebhook

defaultAllow: false

With defaultAllow: false, if the webhook service is unreachable, ALL pod creation is denied. This is the most secure setting but requires the webhook to be highly available. Read the exam question carefully to determine which setting is required.


Solution 14 -- Admission Controllers: ValidatingWebhookConfiguration

Concepts tested: ValidatingWebhookConfiguration, service reference, namespace selector

bash
# First, get the CA bundle
CA_BUNDLE=$(cat /etc/kubernetes/webhook/ca.crt | base64 -w 0)
yaml
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: pod-security-webhook
webhooks:
- name: pod-validator.webhook-system.svc
  admissionReviewVersions: ["v1", "v1beta1"]
  sideEffects: None
  clientConfig:
    service:
      name: pod-validator
      namespace: webhook-system
      path: /validate
      port: 443
    caBundle: <BASE64_CA_BUNDLE>
  rules:
  - apiGroups: [""]
    apiVersions: ["v1"]
    operations: ["CREATE", "UPDATE"]
    resources: ["pods"]
    scope: "Namespaced"
  failurePolicy: Fail
  matchPolicy: Equivalent
  namespaceSelector:
    matchExpressions:
    - key: kubernetes.io/metadata.name
      operator: NotIn
      values: ["kube-system"]
  timeoutSeconds: 10
bash
# Replace the CA bundle and apply
sed "s|<BASE64_CA_BUNDLE>|${CA_BUNDLE}|g" webhook-config.yaml | kubectl apply -f -

# Verify
kubectl get validatingwebhookconfigurations pod-security-webhook

Key Fields

  • admissionReviewVersions: ["v1"] -- Required, specifies the API version
  • sideEffects: None -- Indicates the webhook has no side effects (required)
  • failurePolicy: Fail -- Fail closed is more secure
  • namespaceSelector -- Excludes kube-system from validation
  • timeoutSeconds: 10 -- Prevents slow webhooks from blocking API requests

Solution 15 -- Admission Controllers: Troubleshooting

Concepts tested: Webhook debugging, failure recovery

bash
# Step 1: Identify the webhook causing rejections
kubectl get validatingwebhookconfigurations
kubectl get mutatingwebhookconfigurations

# Check which webhook is blocking (look at the error message)
kubectl run test --image=nginx -n production --dry-run=server 2>&1

# Step 2: Check webhook service health
kubectl get pods -n webhook-system
# If crashed: NAME                     READY   STATUS    RESTARTS
#              webhook-xxx              0/1     CrashLoopBackOff

kubectl get endpoints -n webhook-system
# If no endpoints, the service has no healthy backends

# Step 3: Immediate fix options

# Option A: If webhook failurePolicy is Fail, temporarily change to Ignore
kubectl get validatingwebhookconfigurations <name> -o yaml > webhook-backup.yaml
kubectl patch validatingwebhookconfigurations <name> \
  --type='json' -p='[{"op":"replace","path":"/webhooks/0/failurePolicy","value":"Ignore"}]'

# Option B: Delete the webhook configuration temporarily
kubectl delete validatingwebhookconfigurations <name>
# (Restore later from backup)

# Option C: Fix the crashed webhook pod
kubectl logs -n webhook-system <pod-name> --previous
kubectl describe pod -n webhook-system <pod-name>
# Fix the underlying issue (resource limits, image issue, config error)

# Step 4: After fixing the webhook service, restore Fail policy
kubectl apply -f webhook-backup.yaml

Exam Strategy

On the exam, the quickest fix is usually Option A (change failurePolicy to Ignore temporarily). This maintains the webhook configuration while allowing pod creation. Investigate and fix the root cause after restoring service availability.


Solution 16 -- Container Hardening: Non-Root with Read-Only FS

Concepts tested: Complete pod hardening, multiple security layers

yaml
apiVersion: v1
kind: Pod
metadata:
  name: hardened-api
spec:
  automountServiceAccountToken: false
  securityContext:
    runAsUser: 10001
    runAsGroup: 10001
    runAsNonRoot: true
    fsGroup: 10001
    seccompProfile:
      type: RuntimeDefault
  containers:
  - name: api
    image: python:3.12-slim
    command: ["python", "-m", "http.server", "8080"]
    securityContext:
      readOnlyRootFilesystem: true
      allowPrivilegeEscalation: false
      capabilities:
        drop:
        - ALL
    resources:
      requests:
        memory: "64Mi"
        cpu: "125m"
      limits:
        memory: "128Mi"
        cpu: "250m"
    ports:
    - containerPort: 8080
    volumeMounts:
    - name: tmp
      mountPath: /tmp
  volumes:
  - name: tmp
    emptyDir:
      sizeLimit: 64Mi
bash
kubectl apply -f hardened-api.yaml

# Verify all hardening measures
kubectl exec hardened-api -- id
# uid=10001 gid=10001 groups=10001

kubectl exec hardened-api -- touch /test 2>&1
# Read-only file system

kubectl exec hardened-api -- cat /proc/1/status | grep -i seccomp
# Seccomp: 2 (filter mode)

kubectl exec hardened-api -- ls /var/run/secrets/kubernetes.io/serviceaccount/ 2>&1
# No such file or directory

kubectl describe pod hardened-api | grep -A5 Limits

Solution 17 -- Container Hardening: Identify and Fix Vulnerabilities

Concepts tested: Vulnerability identification, security remediation

Vulnerabilities Identified

IssueSeverityProblem
image: ubuntu:latestHighMutable tag, large image with many packages
apt-get install in commandHighInstalling packages at runtime, non-reproducible
privileged: trueCriticalFull host access, container escape possible
Hardcoded password in envCriticalSecret in plain text in pod spec
No resource limitsMediumVulnerable to DoS, resource starvation
Running as rootHighDefault root user, no securityContext
No read-only filesystemMediumWritable root FS allows file drops
No capability restrictionsMediumDefault capabilities too permissive

Hardened Version

bash
# First, create a proper secret
kubectl create secret generic db-creds -n audit \
  --from-literal=DB_PASSWORD='plaintext-password-123'
yaml
apiVersion: v1
kind: Pod
metadata:
  name: vulnerable-app
  namespace: audit
spec:
  automountServiceAccountToken: false
  securityContext:
    runAsUser: 10001
    runAsGroup: 10001
    runAsNonRoot: true
    seccompProfile:
      type: RuntimeDefault
  containers:
  - name: app
    image: alpine:3.19                  # Minimal, pinned tag
    command: ["sleep", "3600"]          # No runtime package installs
    securityContext:
      privileged: false                 # REMOVED privileged
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop:
        - ALL
    # Secret mounted as volume, NOT env var with plain text
    volumeMounts:
    - name: db-secret
      mountPath: /etc/secrets
      readOnly: true
    - name: tmp
      mountPath: /tmp
    resources:
      limits:
        memory: "128Mi"
        cpu: "250m"
      requests:
        memory: "64Mi"
        cpu: "125m"
    ports:
    - containerPort: 8080
  volumes:
  - name: db-secret
    secret:
      secretName: db-creds
      defaultMode: 0400
  - name: tmp
    emptyDir: {}

Solution 18 -- Combined: SecurityContext + Secrets

Concepts tested: Secret volume mounts with security context, writable directories

yaml
apiVersion: v1
kind: Pod
metadata:
  name: secure-db-client
  namespace: production
spec:
  securityContext:
    runAsUser: 70
    runAsGroup: 70
    runAsNonRoot: true
    fsGroup: 70
  containers:
  - name: db-client
    image: postgres:16-alpine
    command: ["sleep", "3600"]
    securityContext:
      readOnlyRootFilesystem: true
      allowPrivilegeEscalation: false
      capabilities:
        drop:
        - ALL
    volumeMounts:
    - name: db-secrets
      mountPath: /etc/db-secrets
      readOnly: true
    - name: tmp
      mountPath: /tmp
    - name: pg-run
      mountPath: /var/run/postgresql
  volumes:
  - name: db-secrets
    secret:
      secretName: pg-credentials
      defaultMode: 0400
  - name: tmp
    emptyDir: {}
  - name: pg-run
    emptyDir: {}
bash
kubectl apply -f secure-db-client.yaml

# Verify
kubectl exec -n production secure-db-client -- id
# uid=70 gid=70 groups=70

kubectl exec -n production secure-db-client -- ls -la /etc/db-secrets/
# -r-------- 1 root 70 ... PGPASSWORD
# -r-------- 1 root 70 ... PGUSER

kubectl exec -n production secure-db-client -- cat /etc/db-secrets/PGPASSWORD
# (password value)

Why UID 70?

The official PostgreSQL Docker images use UID/GID 70 for the postgres user. Using the correct UID ensures the application has proper file access. The fsGroup: 70 ensures mounted volumes are accessible to the postgres group.


Solution 19 -- Combined: RuntimeClass + SecurityContext

Concepts tested: Maximum isolation combining sandbox runtime with security contexts

RuntimeClass

yaml
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc

Namespace

bash
kubectl create namespace restricted

Pod

yaml
apiVersion: v1
kind: Pod
metadata:
  name: high-security-pod
  namespace: restricted
spec:
  runtimeClassName: gvisor
  automountServiceAccountToken: false
  securityContext:
    runAsUser: 65532
    runAsGroup: 65532
    runAsNonRoot: true
    fsGroup: 65532
    seccompProfile:
      type: RuntimeDefault
  containers:
  - name: app
    image: gcr.io/distroless/static:nonroot
    command: ["/bin/sleep", "3600"]
    securityContext:
      readOnlyRootFilesystem: true
      allowPrivilegeEscalation: false
      capabilities:
        drop:
        - ALL
    resources:
      requests:
        memory: "32Mi"
        cpu: "50m"
      limits:
        memory: "64Mi"
        cpu: "100m"
bash
kubectl apply -f runtimeclass-gvisor.yaml
kubectl apply -f high-security-pod.yaml

# Verify
kubectl get pod -n restricted high-security-pod -o jsonpath='{.spec.runtimeClassName}'
# gvisor

kubectl exec -n restricted high-security-pod -- id 2>/dev/null || echo "No shell (distroless)"

Defense in Depth

This pod has THREE layers of isolation:

  1. gVisor sandbox -- User-space kernel intercepts syscalls
  2. SecurityContext -- Non-root, no capabilities, no escalation
  3. Distroless image -- No shell, no package manager, minimal attack surface

Solution 20 -- Combined: Gatekeeper + SecurityContext

Concepts tested: Creating policy and compliant resources

ConstraintTemplate

yaml
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8srequirereadonlyfs
spec:
  crd:
    spec:
      names:
        kind: K8sRequireReadOnlyFS
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8srequirereadonlyfs

        violation[{"msg": msg}] {
          container := input.review.object.spec.containers[_]
          not container.securityContext.readOnlyRootFilesystem
          msg := sprintf(
            "Container '%v' must have readOnlyRootFilesystem set to true",
            [container.name]
          )
        }

Constraint

yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequireReadOnlyFS
metadata:
  name: require-readonly-fs
spec:
  enforcementAction: deny
  match:
    kinds:
    - apiGroups: [""]
      kinds: ["Pod"]
    namespaces:
    - production

Non-compliant pod (will be rejected)

bash
kubectl run non-compliant --image=nginx -n production --dry-run=server
# Error: Container 'non-compliant' must have readOnlyRootFilesystem set to true

Compliant pod

yaml
apiVersion: v1
kind: Pod
metadata:
  name: compliant-app
  namespace: production
spec:
  containers:
  - name: nginx
    image: nginx:1.25
    securityContext:
      readOnlyRootFilesystem: true
    volumeMounts:
    - name: cache
      mountPath: /var/cache/nginx
    - name: run
      mountPath: /var/run
    - name: tmp
      mountPath: /tmp
  volumes:
  - name: cache
    emptyDir: {}
  - name: run
    emptyDir: {}
  - name: tmp
    emptyDir: {}
bash
kubectl apply -f compliant-app.yaml
# pod/compliant-app created

Solution 21 -- Secrets: Key Rotation

Concepts tested: EncryptionConfiguration key rotation, re-encryption

Step 1: Generate new key

bash
head -c 32 /dev/urandom | base64
# Example: bmV3a2V5Zm9yZW5jcnlwdGlvbnJvdGF0aW9uMzJieXRl

Step 2: Update EncryptionConfiguration

yaml
# /etc/kubernetes/enc/encryption-config.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
  - resources:
    - secrets
    providers:
    - aescbc:
        keys:
        - name: key2                                              # NEW key first
          secret: bmV3a2V5Zm9yZW5jcnlwdGlvbnJvdGF0aW9uMzJieXRl
        - name: key1                                              # OLD key second
          secret: aGVsbG93b3JsZHRoaXNpc2EzMmJ5dGVrZXlmb3J0ZXN0
    - identity: {}

Step 3: Restart API server

bash
# The kubelet detects the file change and restarts the API server
# If it doesn't auto-restart, touch the manifest:
sudo touch /etc/kubernetes/manifests/kube-apiserver.yaml

# Wait for restart
kubectl get nodes  # Retry until working

Step 4: Re-encrypt all secrets

bash
kubectl get secrets --all-namespaces -o json | kubectl replace -f -

Step 5: Verify new key is used

bash
kubectl create secret generic rotation-test --from-literal=key=value

ETCDCTL_API=3 etcdctl get /registry/secrets/default/rotation-test \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key

# Should show: k8s:enc:aescbc:v1:key2: (note key2, not key1)

Key Rotation Order

  1. New key (key2) goes first -- used for new encryption
  2. Old key (key1) goes second -- used for decrypting existing secrets
  3. After re-encrypting all secrets, you can safely remove key1
  4. Always keep identity: {} last as the final fallback

Solution 22 -- Admission Controllers: Mutating vs Validating Order

Concepts tested: Admission flow understanding, webhook ordering

Answers

1. Yes, the pod creation will succeed. The admission chain order is:

  1. Mutating webhooks run first -- the MutatingWebhookConfiguration adds validated: true
  2. Schema validation runs
  3. Validating webhooks run second -- the ValidatingWebhookConfiguration sees the validated: true label and allows the pod

2. reinvocationPolicy: IfNeeded tells Kubernetes to re-run this mutating webhook if another mutating webhook modified the object after it ran. This ensures the webhook sees the final state of the object.

3. When failurePolicy: Fail is set and the webhook is down, the API request is rejected. This is "fail closed" behavior -- secure but can cause availability issues.

MutatingWebhookConfiguration YAML

yaml
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  name: add-validated-label
webhooks:
- name: add-label.example.com
  admissionReviewVersions: ["v1"]
  sideEffects: None
  reinvocationPolicy: IfNeeded
  clientConfig:
    service:
      name: label-mutator
      namespace: webhook-system
      path: /mutate
      port: 443
    caBundle: <BASE64_CA>
  rules:
  - apiGroups: [""]
    apiVersions: ["v1"]
    operations: ["CREATE"]
    resources: ["pods"]
  failurePolicy: Fail
  namespaceSelector:
    matchExpressions:
    - key: kubernetes.io/metadata.name
      operator: NotIn
      values: ["kube-system"]
  timeoutSeconds: 10

ValidatingWebhookConfiguration YAML

yaml
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: require-validated-label
webhooks:
- name: check-label.example.com
  admissionReviewVersions: ["v1"]
  sideEffects: None
  clientConfig:
    service:
      name: label-validator
      namespace: webhook-system
      path: /validate
      port: 443
    caBundle: <BASE64_CA>
  rules:
  - apiGroups: [""]
    apiVersions: ["v1"]
    operations: ["CREATE"]
    resources: ["pods"]
  failurePolicy: Fail
  namespaceSelector:
    matchExpressions:
    - key: kubernetes.io/metadata.name
      operator: NotIn
      values: ["kube-system"]
  timeoutSeconds: 10

Solution 23 -- mTLS: PeerAuthentication

Concepts tested: Istio mTLS configuration, AuthorizationPolicy

Mesh-wide STRICT mTLS

yaml
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
  name: default
  namespace: istio-system
spec:
  mtls:
    mode: STRICT

Namespace-level PERMISSIVE for legacy

yaml
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
  name: default
  namespace: legacy
spec:
  mtls:
    mode: PERMISSIVE

AuthorizationPolicy

yaml
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: allow-frontend-only
  namespace: production
spec:
  selector:
    matchLabels:
      app: api-server
  action: ALLOW
  rules:
  - from:
    - source:
        principals:
        - "cluster.local/ns/production/sa/frontend"
    to:
    - operation:
        methods: ["GET", "POST"]
        paths: ["/api/*"]
bash
kubectl apply -f mesh-strict.yaml
kubectl apply -f legacy-permissive.yaml
kubectl apply -f authz-policy.yaml

# Verify
kubectl get peerauthentication -A
kubectl get authorizationpolicy -n production

Why PERMISSIVE for Legacy?

The legacy namespace may contain services that are not part of the mesh (no sidecar proxy). STRICT mode would reject their connections. PERMISSIVE allows both mTLS and plain text traffic, enabling a gradual migration.


Solution 24 -- Combined: Full Pod Hardening

Concepts tested: Complete deployment hardening with all security measures

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web-app
  namespace: production
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web-app
  template:
    metadata:
      labels:
        app: web-app
    spec:
      automountServiceAccountToken: false
      securityContext:
        runAsUser: 101
        runAsGroup: 101
        runAsNonRoot: true
        fsGroup: 101
        seccompProfile:
          type: RuntimeDefault
      containers:
      - name: web
        image: nginx:1.25                   # Pinned tag, not 'latest'
        ports:
        - containerPort: 80
        securityContext:
          readOnlyRootFilesystem: true
          allowPrivilegeEscalation: false
          capabilities:
            drop:
            - ALL
            add:
            - NET_BIND_SERVICE              # Required for port 80
        resources:
          requests:
            memory: "128Mi"
            cpu: "250m"
          limits:
            memory: "256Mi"
            cpu: "500m"
        volumeMounts:
        - name: cache
          mountPath: /var/cache/nginx
        - name: run
          mountPath: /var/run
        - name: tmp
          mountPath: /tmp
      volumes:
      - name: cache
        emptyDir: {}
      - name: run
        emptyDir: {}
      - name: tmp
        emptyDir: {}
bash
kubectl apply -f hardened-deployment.yaml

# Verify rollout
kubectl rollout status deployment web-app -n production

# Verify security settings
kubectl exec -n production deploy/web-app -- id
# uid=101 gid=101

kubectl exec -n production deploy/web-app -- touch /test 2>&1
# Read-only file system

kubectl describe deployment web-app -n production | grep -A5 Limits

Why NET_BIND_SERVICE?

Nginx binds to port 80, which is below 1024. Non-root processes need the NET_BIND_SERVICE capability to bind to privileged ports. We drop ALL capabilities and add back only this one.


Solution 25 -- Combined: Multi-Layer Security

Concepts tested: All Domain 3 concepts combined

Part 1: Verify/Setup Secret Encryption

bash
# Check if encryption is configured
cat /etc/kubernetes/manifests/kube-apiserver.yaml | grep encryption-provider-config

If not configured:

bash
head -c 32 /dev/urandom | base64
# Generate key, e.g.: c2VjcmV0Ym94a2V5Zm9ydGhlbXVsdGlsYXllcnRlc3Q=

sudo mkdir -p /etc/kubernetes/enc
yaml
# /etc/kubernetes/enc/encryption-config.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
  - resources:
    - secrets
    providers:
    - secretbox:
        keys:
        - name: key1
          secret: c2VjcmV0Ym94a2V5Zm9ydGhlbXVsdGlsYXllcnRlc3Q=
    - identity: {}

Update kube-apiserver.yaml with --encryption-provider-config=/etc/kubernetes/enc/encryption-config.yaml and the volume mount (see Solution 4 for details).

Part 2: OPA Policy for runAsNonRoot

ConstraintTemplate:

yaml
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8srequirenonroot
spec:
  crd:
    spec:
      names:
        kind: K8sRequireNonRoot
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8srequirenonroot

        violation[{"msg": msg}] {
          not pod_or_containers_nonroot
          msg := "Pod must have runAsNonRoot set to true at pod level or all containers"
        }

        pod_or_containers_nonroot {
          input.review.object.spec.securityContext.runAsNonRoot == true
        }

        pod_or_containers_nonroot {
          containers := input.review.object.spec.containers
          count(containers) > 0
          all_containers_nonroot
        }

        all_containers_nonroot {
          container := input.review.object.spec.containers[_]
          container.securityContext.runAsNonRoot == true
        }

Constraint:

yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequireNonRoot
metadata:
  name: require-nonroot-production
spec:
  enforcementAction: deny
  match:
    kinds:
    - apiGroups: [""]
      kinds: ["Pod"]
    namespaces:
    - production

Part 3: RuntimeClass

yaml
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc

Part 4: Create the Secret and Pod

bash
# Create the namespace
kubectl create namespace production

# Create the secret
kubectl create secret generic app-secret -n production \
  --from-literal=api-key=sk-12345
yaml
apiVersion: v1
kind: Pod
metadata:
  name: fortress
  namespace: production
spec:
  runtimeClassName: gvisor
  automountServiceAccountToken: false
  securityContext:
    runAsUser: 65532
    runAsGroup: 65532
    runAsNonRoot: true
    fsGroup: 65532
    seccompProfile:
      type: RuntimeDefault
  containers:
  - name: app
    image: gcr.io/distroless/static:nonroot
    command: ["/bin/sleep", "3600"]
    securityContext:
      readOnlyRootFilesystem: true
      allowPrivilegeEscalation: false
      capabilities:
        drop:
        - ALL
    resources:
      limits:
        memory: "64Mi"
        cpu: "100m"
    volumeMounts:
    - name: secrets
      mountPath: /etc/secrets
      readOnly: true
    - name: tmp
      mountPath: /tmp
  volumes:
  - name: secrets
    secret:
      secretName: app-secret
      defaultMode: 0400
  - name: tmp
    emptyDir:
      sizeLimit: 16Mi
bash
# Apply everything in order
kubectl apply -f constraint-template-nonroot.yaml
sleep 5
kubectl apply -f constraint-nonroot.yaml
kubectl apply -f runtimeclass-gvisor.yaml
kubectl apply -f fortress-pod.yaml

# Verify
echo "=== RuntimeClass ==="
kubectl get pod fortress -n production -o jsonpath='{.spec.runtimeClassName}'

echo "=== Security Context ==="
kubectl get pod fortress -n production -o jsonpath='{.spec.securityContext}'

echo "=== Secret Mount ==="
kubectl exec -n production fortress -- ls -la /etc/secrets/ 2>/dev/null || echo "No shell (distroless)"

echo "=== Encryption in etcd ==="
ETCDCTL_API=3 etcdctl get /registry/secrets/production/app-secret \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key \
  | hexdump -C | head -5
# Should show k8s:enc:secretbox: prefix

echo "=== OPA Policy Test ==="
kubectl run rootpod --image=nginx -n production --dry-run=server 2>&1
# Should be denied by the Gatekeeper policy

Multi-Layer Security Summary

This solution demonstrates all four layers of Domain 3 security:

  1. Encryption at rest -- Secret is encrypted in etcd with secretbox
  2. Admission control -- OPA/Gatekeeper rejects non-compliant pods
  3. Runtime sandboxing -- gVisor provides kernel-level isolation
  4. Container hardening -- Non-root, read-only FS, no capabilities, distroless image

Each layer compensates for potential weaknesses in the others, providing defense in depth.

Released under the MIT License.