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:
# Export current pod spec
kubectl get pod legacy-app -n production -o yaml > legacy-app.yamlEdit the pod spec to add the security context:
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# 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=1000Why This Works
runAsUser: 1000forces the container to run as UID 1000 instead of rootrunAsNonRoot: trueis a safety net -- Kubernetes rejects the pod if it somehow tries to run as rootallowPrivilegeEscalation: falsesets theno_new_privsflag, preventing SUID exploitsdrop: ["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
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: {}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 -5Why 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
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: {}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,3000Why 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
# Generate a 32-byte random key, base64-encoded
head -c 32 /dev/urandom | base64
# Example output: aGVsbG93b3JsZHRoaXNpc2EzMmJ5dGVrZXlmb3J0ZXN0Step 2: Create the EncryptionConfiguration
sudo mkdir -p /etc/kubernetes/enc# /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: {}sudo vi /etc/kubernetes/enc/encryption-config.yaml
# (paste the above content)Step 3: Configure the API server
sudo vi /etc/kubernetes/manifests/kube-apiserver.yamlAdd the following to the kube-apiserver command:
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: DirectoryOrCreateStep 4: Wait for API server restart
# Watch for the API server to restart
watch crictl ps | grep kube-apiserver
# Or keep trying:
kubectl get nodesStep 5: Create a test secret
kubectl create secret generic encrypted-secret \
--from-literal=password=SuperSecret123Step 6: Verify encryption in etcd
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 workingCritical 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
# First, ensure the secret exists
kubectl get secret db-credentials -n securityCreate the refactored pod:
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# 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: 0400restricts access to the file owner onlyreadOnly: trueprevents 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
# 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.yamlStep 2: Inspect the EncryptionConfiguration
sudo cat /etc/kubernetes/enc/encryption-config.yaml
# Look for the provider type (aescbc, secretbox, aesgcm, identity)
# Verify identity is NOT the first providerStep 3: Create a test secret
kubectl create secret generic enc-test --from-literal=testkey=verify-meStep 4: Read directly from etcd
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
kubectl get secret enc-test -o jsonpath='{.data.testkey}' | base64 -d
# verify-meVerification 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
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
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"# 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:
provided-- set of labels the object already hasrequired-- set of labels from the constraint parametersmissing-- set difference (required - provided)- If
missinghas any elements, it is a violation
Solution 8 -- OPA/Gatekeeper: Deny Privileged Containers
Concepts tested: Rego for container iteration, init containers, excluded namespaces
ConstraintTemplate
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
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sDenyPrivileged
metadata:
name: no-privileged-pods
spec:
enforcementAction: deny
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
excludedNamespaces:
- kube-system
- gatekeeper-systemkubectl 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
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)
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/"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 repositorySolution 10 -- OPA/Gatekeeper: Audit Mode
Concepts tested: enforcementAction, audit results, constraint status
# 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
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: gvisor
handler: runscPod
apiVersion: v1
kind: Pod
metadata:
name: sandboxed-app
namespace: default
spec:
runtimeClassName: gvisor
containers:
- name: web
image: nginx:1.25
ports:
- containerPort: 80kubectl 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
# 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"]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
# /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: falseStep 2: Create the kubeconfig
# /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-bouncerStep 3: Configure the API server
sudo vi /etc/kubernetes/manifests/kube-apiserver.yamlAdd/modify these sections:
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: DirectoryOrCreateStep 4: Verify
# 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 ImagePolicyWebhookdefaultAllow: 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
# First, get the CA bundle
CA_BUNDLE=$(cat /etc/kubernetes/webhook/ca.crt | base64 -w 0)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# 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-webhookKey Fields
admissionReviewVersions: ["v1"]-- Required, specifies the API versionsideEffects: None-- Indicates the webhook has no side effects (required)failurePolicy: Fail-- Fail closed is more securenamespaceSelector-- Excludes kube-system from validationtimeoutSeconds: 10-- Prevents slow webhooks from blocking API requests
Solution 15 -- Admission Controllers: Troubleshooting
Concepts tested: Webhook debugging, failure recovery
# 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.yamlExam 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
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: 64Mikubectl 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 LimitsSolution 17 -- Container Hardening: Identify and Fix Vulnerabilities
Concepts tested: Vulnerability identification, security remediation
Vulnerabilities Identified
| Issue | Severity | Problem |
|---|---|---|
image: ubuntu:latest | High | Mutable tag, large image with many packages |
apt-get install in command | High | Installing packages at runtime, non-reproducible |
privileged: true | Critical | Full host access, container escape possible |
| Hardcoded password in env | Critical | Secret in plain text in pod spec |
| No resource limits | Medium | Vulnerable to DoS, resource starvation |
| Running as root | High | Default root user, no securityContext |
| No read-only filesystem | Medium | Writable root FS allows file drops |
| No capability restrictions | Medium | Default capabilities too permissive |
Hardened Version
# First, create a proper secret
kubectl create secret generic db-creds -n audit \
--from-literal=DB_PASSWORD='plaintext-password-123'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
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: {}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
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: gvisor
handler: runscNamespace
kubectl create namespace restrictedPod
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"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:
- gVisor sandbox -- User-space kernel intercepts syscalls
- SecurityContext -- Non-root, no capabilities, no escalation
- Distroless image -- No shell, no package manager, minimal attack surface
Solution 20 -- Combined: Gatekeeper + SecurityContext
Concepts tested: Creating policy and compliant resources
ConstraintTemplate
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
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequireReadOnlyFS
metadata:
name: require-readonly-fs
spec:
enforcementAction: deny
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
namespaces:
- productionNon-compliant pod (will be rejected)
kubectl run non-compliant --image=nginx -n production --dry-run=server
# Error: Container 'non-compliant' must have readOnlyRootFilesystem set to trueCompliant pod
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: {}kubectl apply -f compliant-app.yaml
# pod/compliant-app createdSolution 21 -- Secrets: Key Rotation
Concepts tested: EncryptionConfiguration key rotation, re-encryption
Step 1: Generate new key
head -c 32 /dev/urandom | base64
# Example: bmV3a2V5Zm9yZW5jcnlwdGlvbnJvdGF0aW9uMzJieXRlStep 2: Update EncryptionConfiguration
# /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
# 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 workingStep 4: Re-encrypt all secrets
kubectl get secrets --all-namespaces -o json | kubectl replace -f -Step 5: Verify new key is used
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
- New key (
key2) goes first -- used for new encryption - Old key (
key1) goes second -- used for decrypting existing secrets - After re-encrypting all secrets, you can safely remove
key1 - 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:
- Mutating webhooks run first -- the MutatingWebhookConfiguration adds
validated: true - Schema validation runs
- Validating webhooks run second -- the ValidatingWebhookConfiguration sees the
validated: truelabel 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
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: 10ValidatingWebhookConfiguration 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: 10Solution 23 -- mTLS: PeerAuthentication
Concepts tested: Istio mTLS configuration, AuthorizationPolicy
Mesh-wide STRICT mTLS
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
name: default
namespace: istio-system
spec:
mtls:
mode: STRICTNamespace-level PERMISSIVE for legacy
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
name: default
namespace: legacy
spec:
mtls:
mode: PERMISSIVEAuthorizationPolicy
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/*"]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 productionWhy 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
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: {}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 LimitsWhy 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
# Check if encryption is configured
cat /etc/kubernetes/manifests/kube-apiserver.yaml | grep encryption-provider-configIf not configured:
head -c 32 /dev/urandom | base64
# Generate key, e.g.: c2VjcmV0Ym94a2V5Zm9ydGhlbXVsdGlsYXllcnRlc3Q=
sudo mkdir -p /etc/kubernetes/enc# /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:
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:
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequireNonRoot
metadata:
name: require-nonroot-production
spec:
enforcementAction: deny
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
namespaces:
- productionPart 3: RuntimeClass
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: gvisor
handler: runscPart 4: Create the Secret and Pod
# Create the namespace
kubectl create namespace production
# Create the secret
kubectl create secret generic app-secret -n production \
--from-literal=api-key=sk-12345apiVersion: 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# 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 policyMulti-Layer Security Summary
This solution demonstrates all four layers of Domain 3 security:
- Encryption at rest -- Secret is encrypted in etcd with secretbox
- Admission control -- OPA/Gatekeeper rejects non-compliant pods
- Runtime sandboxing -- gVisor provides kernel-level isolation
- Container hardening -- Non-root, read-only FS, no capabilities, distroless image
Each layer compensates for potential weaknesses in the others, providing defense in depth.