CKS Mock Exam · Set 2 — 17 tasks, one clock, 100 points
This is a second full CKS-format sitting: 17 performance-based hardening tasks, 100 points, weighted 15/15/10/20/20/20 to match the six official CKS domains from the CKS blueprint, against one unbroken 2-hour block on a cluster you build yourself. Every task hands you a real, working (or deliberately unhardened) piece of a kubeadm lab — an open NetworkPolicy gap, a ServiceAccount bound to cluster-admin by accident, a Secret sitting in plaintext, an unsigned image running in production — and grades only the state you leave it in, exactly like the real exam does. If you haven't sat Set 1 yet, sit that one first; this paper is unseen material and stays most useful if you come to it cold. Each task carries a done-when check you can grade yourself against, and a full worked solution — real commands, real YAML — folded behind a summary so you're never tempted to peek before you've actually tried. If any domain still feels shaky, work the CKS study plan and the CKS blueprint first; this paper measures execution under a clock, not familiarity with the reading.
You already earned the clubhouse's night-guard badge in principle. Today a surprise inspector shows up, hands you a clipboard listing seventeen very specific things wrong right now — a window with no lock, a delivery box nobody checked, a spare key taped under the mat, a logbook nobody's been writing in — starts a two-hour timer, and leaves. You don't get to explain what a good lock looks like. You have to actually go lock the window, check the box, hide the key properly, and start the logbook, then prove each fix works before the timer runs out. Some fixes are five-minute jobs. One or two eat twelve minutes each. The inspector doesn't care which order you do them in, only what's actually fixed when the buzzer sounds.
How this paper is built, and where it fits your prep
☺ Like you're 10: This is a full practice game against a clock, using the same six skills you've been drilling one at a time.
The CNCF doesn't publish which task is worth how many points, but it does publish the six domains' weights, and this paper takes them at face value: 15 points for Cluster Setup, 15 for Cluster Hardening, 10 for System Hardening, 20 for Minimize Microservice Vulnerabilities, 20 for Supply Chain Security, and 20 for Monitoring, Logging & Runtime Security — the exact split the CKS blueprint lays out and the study plan builds its weeks around. Below, that turns into 17 tasks (3 + 3 + 2 + 3 + 3 + 3), each with its own point value and done-when check. For teaching clarity this page groups tasks by domain, with the worked solution sitting right below each one — the real exam has no such courtesy, drawing tasks across several separate clusters in an order that ignores domain boundaries entirely. Once you've sat this paper and read every solution, get a more realistic second rep by writing the seventeen task numbers on paper, shuffling them, and re-attempting your own cluster in that order with the solutions covered.
| Stage | What you do | What it tells you |
|---|---|---|
| 1 · Build fluency | Work the CKS blueprint domain by domain and make sure your CKA-level kubectl and static-manifest reflexes from the CKA material are already fast — CKS assumes them. | Whether you know the material at all — untimed. |
| 2 · Sit Set 1 | CKS Mock Exam · Set 1 — a first cold rep at the full 100-point paper. | Your baseline pacing across all six domains. |
| 3 · Fix gaps | CKS Practice Tasks and Capstone Part 5 or the RBAC-hardening drill, targeted at whichever domains dropped points. | Converts a diagnosis into targeted practice instead of a full re-read. |
| 4 · Sit Set 2 | This page — unseen tasks, sat cold, ideally closer to your real exam date. | Your actual readiness, not just familiarity with Set 1's answers. |
Three domains worth 20 points each — Minimize Microservice Vulnerabilities, Supply Chain Security, and Monitoring/Logging/Runtime Security — together outweigh the other three combined, 60 to 40. A candidate fluent in RBAC and NetworkPolicy but shaky on cosign signatures and audit logs can still fail outright, while one who's average across the board but solid on all three 20-point domains has real room to spare. Score yourself by domain, not just by total.
Before you start — build the lab cluster yourself
☺ Like you're 10: There's no download link for "one clubhouse with seventeen real security gaps" — you build it yourself, from real materials, so the fixes you make are fixes you actually earned.
This page has no vendor-hosted environment behind it. Build a throwaway 3-node kubeadm cluster the night before — control-plane node cks-mock2-cp and workers cks-mock2-w1 / cks-mock2-w2, all on Kubernetes v1.35.0 with containerd and Calico as the CNI (it must actually enforce NetworkPolicy — confirm this before the clock starts). Install ingress-nginx, kube-bench, trivy, cosign, and Kyverno; on cks-mock2-w2 only, pre-configure containerd with a runsc (gVisor) runtime handler. Seed the following objects, then confirm the lab is healthy before the timer starts:
# --- the night before ---------------------------------------------------
kubectl get nodes -o wide
# cks-mock2-cp Ready control-plane — v1.35.0
# cks-mock2-w1 Ready <none> — v1.35.0
# cks-mock2-w2 Ready <none> — v1.35.0 (this one has the runsc handler)
kubectl get pods -A | grep -vE 'Running|Completed' # anything unhealthy here is a task you'll skip, not fix
# namespace "checkout": cart-api Deployment (2 replicas, app=cart-api, port 8080) + Service,
# ConfigMap cart-config with a plaintext db-password field
# namespace "platform": ServiceAccount deploy-bot mistakenly bound to cluster-admin,
# ServiceAccount worker-sa + Deployment worker (never calls the API)
# namespace "ops": Pod img-resizer (no seccomp/AppArmor), Pod vendor-upload-processor
# (untrusted batch job), Deployment promo-banner (pulls an unapproved
# public image on a mutable tag), Deployment legacy-worker, plus a
# canned Falco alert saved at /var/log/falco-events.log
kubectl get ns checkout platform ops
# self-signed CA + cert/key for the Ingress TLS task, already on cks-mock2-cp
ls /root/pki/checkout.crt /root/pki/checkout.key
# --- ninety seconds before the clock starts ------------------------------
kubectl config use-context cks-mock2
kubectl config set-context --current --namespace=default
kubectl config view --minify -o jsonpath='{..namespace}{"\n"}' # say it out loud
alias k=kubectl
export do="--dry-run=client -o yaml"
export now="--grace-period=0 --force"Task 3.2 (AppArmor) needs a node whose kernel actually has AppArmor available — true on Ubuntu, generally not on a stock minikube VM image or many kind nodes. Task 4.3 (gVisor) needs a node with the runsc handler actually installed. If either is missing from your lab, skip that one task and score it separately as "not attempted — no lab for this one" rather than guessing at it; don't let a missing dependency block the sixteen tasks that don't need it.
Exam conditions for this sitting
☺ Like you're 10: Same rules as the real day — one timer that never pauses, and no peeking at the answer until you've either solved it or spent its full budget.
One timer, started once. 120 minutes. You don't pause it to fix a typo'd YAML file or wait out a slow apt-get mirror — the real exam won't refund that time either. Documentation allowed: the CKS has historically permitted a wider allowlist than the CKA — kubernetes.io/docs plus a short list of named security-project docs (the current CKS Candidate Handbook lists the exact sites). Confirm the live list in your own exam's Important Instructions before test day rather than trusting any fixed list here, and for this practice sitting, restrict yourself to whatever that list currently says — not this site, not your notes, not a search engine, not an AI assistant. No peeking at a worked solution before you've either passed the done-when check or burned the task's full budget. Skim all seventeen task briefs first — five minutes spent scanning the whole paper is the highest-return five minutes of the sitting, because it tells you which tasks close in under five minutes and which one is going to eat twelve.
Each task below carries its own budget, but the mechanical rule is identical everywhere: once a task passes its listed budget with no passing done-when check, stop. Write one line noting where you stalled, leave whatever you've already applied in place — partial credit is real, and a half-finished fix beats a reverted one — and move to the next task. You are not paid to finish tasks in the order they're printed; you are paid to bank points before the clock hits zero.
Your time budget across the two hours
☺ Like you're 10: Give every part of the clubhouse's security sweep its own fair slice of the two hours, and save a few extra minutes at the end just for double-checking your work.
120 minutes splits into 5 to skim, 110 of task work, and a 5-minute verification sweep at the end — and the 110 don't divide evenly across domains, because the domains themselves aren't equal. The three 20-point domains alone claim over half the clock; System Hardening, the smallest domain, claims less than a tenth.
| Block | Tasks | Budget | Running total | Note |
|---|---|---|---|---|
| Skim the whole paper | — | 5 min | 5 | Mark each task cheap / medium / expensive before you touch a terminal. |
| Domain 1 — Cluster Setup | 1.1–1.3 | 7+6+5 = 18 min | 23 | 1.1's NetworkPolicy is the paper's first real time sink — the metadata-egress check is easy to forget. |
| Domain 2 — Cluster Hardening | 2.1–2.3 | 6+4+6 = 16 min | 39 | 2.2 is the fastest single task on the paper — bank it first if you're behind. |
| Domain 3 — System Hardening | 3.1–3.2 | 5+6 = 11 min | 50 | Smallest domain on the paper — don't let it eat more than its share. |
| Domain 4 — Minimize Microservice Vulnerabilities | 4.1–4.3 | 7+7+8 = 22 min | 72 | 4.3's RuntimeClass task is skippable if your lab has no runsc node. |
| Domain 5 — Supply Chain Security | 5.1–5.3 | 6+8+8 = 22 min | 94 | 5.3 depends on 5.1 and 5.2 already being done — work this domain in order. |
| Domain 6 — Monitoring, Logging & Runtime Security | 6.1–6.3 | 7+8+6 = 21 min | 115 | 6.2's incident response is the single highest-value task on the paper at 7 points. |
| Verify & sweep flags | — | 5 min | 120 | Re-run every done-when check. This block earns more points per minute than anything else on the paper. |
Domain 1 — Cluster Setup (15 points)
☺ Like you're 10: This domain is locking the front door, checking who's allowed to knock on which room, and making sure the mail slot doesn't quietly leak the building manager's spare key.
Scenario. Namespace checkout holds Deployment cart-api (label app=cart-api, port 8080) and Service cart-api-svc. ingress-nginx is already installed cluster-wide. A self-signed cert and key for cart.cks-mock2.local already sit at /root/pki/checkout.crt and /root/pki/checkout.key on cks-mock2-cp.
| # | Task | Done when | Points | Budget |
|---|---|---|---|---|
| 1.1 | In checkout, default-deny all ingress and egress, then allow only cart-api Pods to talk to each other on port 8080 and reach CoreDNS — and confirm no Pod there can reach the cloud metadata endpoint 169.254.169.254. | A labeled test Pod can reach cart-api; an unlabeled one can't; DNS still resolves; a curl to 169.254.169.254 times out. | 6 | 7 min |
| 1.2 | Create TLS Secret checkout-tls from the provided cert/key, and an Ingress routing cart.cks-mock2.local to cart-api-svc over HTTPS. | A TLS curl against the ingress controller with the right SNI returns a 200 from cart-api. | 5 | 6 min |
| 1.3 | Run kube-bench against cks-mock2-cp, find the kubelet's read-only port left open, and close it. | Re-running kube-bench shows that finding passing, and cks-mock2-cp stays Ready. | 4 | 5 min |
Worked solution — Domain 1
1.1 — Default-deny, then one scoped path, with DNS and metadata both accounted for. The metadata check is the part people skip: it needs no extra rule at all, because a proper default-deny-egress policy already blocks it — the task is to prove that, not add anything new for it:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: default-deny-all, namespace: checkout }
spec:
podSelector: {}
policyTypes: [Ingress, Egress]
# no rules at all = nothing allowed, in either direction
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: cart-api-allow-self-and-dns, namespace: checkout }
spec:
podSelector:
matchLabels: { app: cart-api }
policyTypes: [Ingress, Egress]
ingress:
- from:
- podSelector: { matchLabels: { app: cart-api } }
ports: [{ protocol: TCP, port: 8080 }]
egress:
- to:
- podSelector: { matchLabels: { app: cart-api } }
ports: [{ protocol: TCP, port: 8080 }]
- to: # DNS — the rule everyone forgets
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: kube-system }
podSelector: { matchLabels: { k8s-app: kube-dns } }
ports: [{ protocol: UDP, port: 53 }, { protocol: TCP, port: 53 }]kubectl run allowed --image=busybox:1.36 -n checkout -l app=cart-api --rm -it --restart=Never \
-- wget -qO- --timeout=3 cart-api-svc.checkout.svc.cluster.local:8080
# <title>cart-api</title>
kubectl run blocked --image=busybox:1.36 -n checkout --rm -it --restart=Never \
-- wget -qO- --timeout=3 cart-api-svc.checkout.svc.cluster.local:8080
# wget: download timed out (no app=cart-api label — correctly blocked)
kubectl run mdcheck --image=busybox:1.36 -n checkout -l app=cart-api --rm -it --restart=Never \
-- wget -qO- --timeout=3 http://169.254.169.254/latest/meta-data/
# wget: download timed out (default-deny-egress covers this with no extra rule needed)1.2 — TLS Secret and Ingress.
kubectl create secret tls checkout-tls -n checkout \
--cert=/root/pki/checkout.crt --key=/root/pki/checkout.keyapiVersion: networking.k8s.io/v1
kind: Ingress
metadata: { name: cart-api-ingress, namespace: checkout }
spec:
ingressClassName: nginx
tls:
- hosts: ["cart.cks-mock2.local"]
secretName: checkout-tls
rules:
- host: cart.cks-mock2.local
http:
paths:
- path: /
pathType: Prefix
backend:
service: { name: cart-api-svc, port: { number: 8080 } }curl -k --resolve cart.cks-mock2.local:443:<ingress-controller-ip> https://cart.cks-mock2.local/
# 200, cart-api's response body1.3 — Close the kubelet's read-only port. kube-bench flags this as a standard CIS finding because port 10255 answers unauthenticated, unencrypted requests for node and Pod metadata — a straightforward reconnaissance target if left open:
kube-bench run --targets node 2>&1 | grep -B1 -A1 "read-only-port"
# [FAIL] Ensure that the --read-only-port argument is set to 0sudo sed -i '/^readOnlyPort:/d' /var/lib/kubelet/config.yaml
echo "readOnlyPort: 0" | sudo tee -a /var/lib/kubelet/config.yaml
sudo systemctl restart kubeletkube-bench run --targets node 2>&1 | grep -A1 "read-only-port"
# [PASS] Ensure that the --read-only-port argument is set to 0
kubectl get nodes
# cks-mock2-cp Ready control-plane — v1.35.0More depth: the CKS blueprint's Cluster Setup section, networking & the CNI, Security: Defense in Depth, ingress-nginx, cert-manager.
Domain 2 — Cluster Hardening (15 points)
☺ Like you're 10: This domain is taking back the spare keys that got handed out too freely, and making sure the front desk actually asks for ID before letting anyone in.
Scenario. Namespace platform: ServiceAccount deploy-bot is currently bound to cluster-admin cluster-wide via ClusterRoleBinding deploy-bot-admin, though it only ever needs to manage Deployments and Services inside checkout. ServiceAccount worker-sa backs Deployment worker, which never calls the API server. The API server on cks-mock2-cp has been deliberately left with --anonymous-auth=true for this exercise.
| # | Task | Done when | Points | Budget |
|---|---|---|---|---|
| 2.1 | Remove deploy-bot's cluster-admin binding and replace it with the minimum RBAC it actually needs in checkout. | kubectl auth can-i '*' '*' --all-namespaces as deploy-bot is no; managing Deployments/Services in checkout is yes. | 6 | 6 min |
| 2.2 | Set automountServiceAccountToken: false on worker-sa, and confirm running worker Pods no longer carry a token after a rollout. | A shell into a fresh worker Pod finds no token mounted under /var/run/secrets/kubernetes.io/serviceaccount/. | 4 | 4 min |
| 2.3 | Disable anonymous authentication on the API server, and verify unauthenticated requests are now genuinely rejected. | An unauthenticated curl to the API server returns 401 Unauthorized; kubectl get nodes still works. | 5 | 6 min |
Worked solution — Domain 2
2.1 — Scope deploy-bot down. Deleting the ClusterRoleBinding first, before the replacement Role exists, is deliberate — it proves the narrower grant is what's actually letting the ServiceAccount work, not a leftover broad one still doing the job unnoticed:
kubectl delete clusterrolebinding deploy-bot-adminapiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: deploy-bot-role, namespace: checkout }
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
- apiGroups: [""]
resources: ["services"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { name: deploy-bot-binding, namespace: checkout }
subjects:
- kind: ServiceAccount
name: deploy-bot
namespace: platform
roleRef: { kind: Role, name: deploy-bot-role, apiGroup: rbac.authorization.k8s.io }kubectl auth can-i '*' '*' --all-namespaces --as=system:serviceaccount:platform:deploy-bot
# no
kubectl auth can-i create deployments -n checkout --as=system:serviceaccount:platform:deploy-bot
# yes2.2 — Disable the default automount. The flag lives on the ServiceAccount, but a Pod that's already running keeps its already-mounted token until it's recreated — a rollout is required, not optional:
kubectl patch serviceaccount worker-sa -n platform -p '{"automountServiceAccountToken": false}'
kubectl rollout restart deployment worker -n platform
kubectl rollout status deployment worker -n platformkubectl exec -n platform deploy/worker -- ls /var/run/secrets/kubernetes.io/serviceaccount/
# ls: /var/run/secrets/kubernetes.io/serviceaccount/: No such file or directory2.3 — Disable anonymous auth on the API server. A malformed static-Pod manifest never produces a Kubernetes-level error anywhere kubectl can show — edit carefully and watch the kubelet's own journal, not kubectl, if it doesn't come back:
# before the fix — an unauthenticated request still gets an *authenticated* identity
curl -sk https://cks-mock2-cp:6443/api/v1/namespaces -o /dev/null -w "%{http_code}\n"
# 403 (rejected as system:anonymous — proof anonymous auth assigned it an identity at all)sudo vi /etc/kubernetes/manifests/kube-apiserver.yaml
# spec.containers[0].command:
# - --anonymous-auth=false
# the kubelet watches this directory and restarts the static Pod on its own — no apply neededcurl -sk https://cks-mock2-cp:6443/api/v1/namespaces -o /dev/null -w "%{http_code}\n"
# 401 (no identity assigned at all now — the correct behavior)
kubectl get nodes
# still succeeds — kubectl authenticates with a real client certificate, not anonymouslyMore depth: the CKS blueprint's Cluster Hardening section, RBAC & Admission Control, hands-on in the RBAC-hardening drill and Capstone Part 5.
Domain 3 — System Hardening (10 points)
☺ Like you're 10: This domain is the rules for what a resident is allowed to touch even once they're already inside their own room — which drawers open, which tools are within reach.
Scenario. Namespace ops: Pod img-resizer is running with no seccomp profile and no AppArmor profile applied — the default, most permissive state for both.
| # | Task | Done when | Points | Budget |
|---|---|---|---|---|
| 3.1 | Set img-resizer's seccomp profile to RuntimeDefault. | kubectl get pod img-resizer -o yaml shows seccompProfile.type: RuntimeDefault, Pod still Running. | 5 | 5 min |
| 3.2 | Apply the node-local AppArmor profile k8s-restricted (already loaded under /etc/apparmor.d on cks-mock2-w1) to img-resizer, and confirm it's actually enforced. | kubectl describe pod img-resizer shows the profile applied, and a write to a path the profile disallows fails inside the container. | 5 | 6 min |
Worked solution — Domain 3
3.1 — seccomp. RuntimeDefault is the container runtime's own curated syscall allowlist — it blocks the syscalls almost nothing legitimate needs (kernel module loading, raw socket tricks, obscure namespace calls) without requiring you to hand-author a profile:
kubectl patch pod img-resizer -n ops --type=json -p='[
{"op":"add","path":"/spec/securityContext","value":{"seccompProfile":{"type":"RuntimeDefault"}}}
]'
# Pods are immutable on most fields — if the patch is rejected, delete and reapply from a
# manifest with securityContext.seccompProfile.type: RuntimeDefault set at the Pod levelkubectl get pod img-resizer -n ops -o jsonpath='{.spec.securityContext.seccompProfile.type}'
# RuntimeDefault
kubectl get pod img-resizer -n ops
# img-resizer 1/1 Running3.2 — AppArmor. The profile is referenced by a Pod annotation, not a securityContext field — a detail that trips people up because every other hardening control in this domain lives under securityContext:
sudo apparmor_parser -q /etc/apparmor.d/k8s-restricted # confirm it's loaded on cks-mock2-w1
sudo aa-status | grep k8s-restrictedapiVersion: v1
kind: Pod
metadata:
name: img-resizer
namespace: ops
annotations:
container.apparmor.security.beta.kubernetes.io/img-resizer: localhost/k8s-restricted
spec:
nodeName: cks-mock2-w1 # the only node with this profile loaded
containers:
- name: img-resizer
image: img-resizer:1.4kubectl describe pod img-resizer -n ops | grep -i apparmor
# Annotations: container.apparmor.security.beta.kubernetes.io/img-resizer: localhost/k8s-restricted
kubectl exec -n ops img-resizer -- sh -c "echo x > /etc/shadow"
# sh: can't create /etc/shadow: Permission denied <-- the profile, not a filesystem permission, is what's blocking thisMore depth: the CKS blueprint's System Hardening section, Security: Defense in Depth, and the sibling DevSecOps course's Kubernetes Security Deep Dive for how these kernel-level controls fit alongside admission and runtime detection.
Domain 4 — Minimize Microservice Vulnerabilities (20 points)
☺ Like you're 10: This domain is what a resident is allowed to do once they're already inside their own apartment — no running with scissors, no borrowing the neighbor's things without asking, and definitely no letting a total stranger stay over without extra precautions.
Scenario. Namespace checkout is not yet labeled for Pod Security Admission; cart-api currently runs as root with no capability restrictions. ConfigMap cart-config in the same namespace holds a plaintext field db-password: hunter2. Separately, in namespace ops, Pod vendor-upload-processor runs code from a third-party vendor that this cluster's owners don't fully trust.
| # | Task | Done when | Points | Budget |
|---|---|---|---|---|
| 4.1 | Label checkout to enforce the restricted Pod Security Standard, then fix cart-api until it satisfies it. | cart-api Pods are Running in a namespace labeled pod-security.kubernetes.io/enforce: restricted. | 7 | 7 min |
| 4.2 | Move db-password out of cart-config into a proper Secret, wire cart-api to consume it from there, and restrict read access to just cart-api's own ServiceAccount. | cart-config no longer contains the password; the app still starts; only cart-api's ServiceAccount can get the Secret. | 6 | 7 min |
| 4.3 | Run vendor-upload-processor under the sandboxed runsc (gVisor) runtime instead of the default. | kubectl get pod vendor-upload-processor -o yaml shows runtimeClassName: gvisor, and the Pod is Running on cks-mock2-w2. | 7 | 8 min |
Worked solution — Domain 4
4.1 — Pod Security Admission, restricted. Label first and let the rejection message itself tell you what's still wrong — it's faster than guessing which of the several restricted-standard requirements you're missing:
kubectl label ns checkout pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=latest \
pod-security.kubernetes.io/warn=restricted
kubectl rollout restart deployment cart-api -n checkout
# Error creating: pods "cart-api-..." is forbidden: violates PodSecurity "restricted:latest":
# allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true,
# seccompProfilespec:
template:
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 10001
seccompProfile: { type: RuntimeDefault }
containers:
- name: cart-api
image: cart-api:2.3
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }kubectl apply -f cart-api-deployment.yaml
kubectl get pods -n checkout -l app=cart-api
# cart-api-7d... 1/1 Running4.2 — Secret, not plaintext ConfigMap. A ConfigMap has no encryption-at-rest guarantee and no separate access controls from any other ConfigMap — a Secret at least gets its own resource type, its own RBAC surface, and (if the cluster enables it) etcd encryption at rest:
kubectl create secret generic cart-db-credentials -n checkout \
--from-literal=db-password=hunter2
kubectl patch configmap cart-config -n checkout --type=json \
-p='[{"op":"remove","path":"/data/db-password"}]' env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef: { name: cart-db-credentials, key: db-password }apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: read-cart-db-secret, namespace: checkout }
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["cart-db-credentials"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { name: cart-api-reads-db-secret, namespace: checkout }
subjects:
- kind: ServiceAccount
name: cart-api
namespace: checkout
roleRef: { kind: Role, name: read-cart-db-secret, apiGroup: rbac.authorization.k8s.io }kubectl get configmap cart-config -n checkout -o yaml | grep db-password # nothing found
kubectl auth can-i get secret/cart-db-credentials -n checkout --as=system:serviceaccount:checkout:cart-api
# yes
kubectl auth can-i get secret/cart-db-credentials -n checkout --as=system:serviceaccount:checkout:default
# no4.3 — Sandboxed runtime for the untrusted workload. RuntimeClass maps a name to a runtime handler configured per-node in containerd; since only cks-mock2-w2 has runsc installed, pin the Pod there explicitly rather than let the scheduler guess and land it somewhere it can't start:
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata: { name: gvisor }
handler: runscapiVersion: v1
kind: Pod
metadata: { name: vendor-upload-processor, namespace: ops }
spec:
runtimeClassName: gvisor
nodeName: cks-mock2-w2
containers:
- name: processor
image: vendor-upload-processor:1.0kubectl apply -f runtimeclass.yaml -f vendor-upload-processor.yaml
kubectl get pod vendor-upload-processor -n ops -o wide
# vendor-upload-processor 1/1 Running cks-mock2-w2
kubectl get pod vendor-upload-processor -n ops -o jsonpath='{.spec.runtimeClassName}'
# gvisorMore depth: the CKS blueprint's Minimize Microservice Vulnerabilities section, RBAC & Admission Control for PSA's place in the admission pipeline, the sibling Platform Engineering course's Secrets & Workload Identity, and Cilium for the pod-to-pod encryption side of this domain this paper doesn't drill.
Domain 5 — Supply Chain Security (20 points)
☺ Like you're 10: This domain checks a delivery before it ever gets carried upstairs — where did this box come from, is the seal on it genuine, and has anyone actually opened it to check what's inside.
Scenario. Namespace ops, Deployment promo-banner currently pulls docker.io/someuser/banner:latest — an unapproved public registry, on a mutable tag. The equivalent image already exists, freshly rebuilt, on the cluster's approved registry: registry.internal/promo-banner. A Kyverno ClusterPolicy named supply-chain-gate, enforcing permitted registries and signature verification, is provided at /root/policies/supply-chain-gate.yaml but not yet applied.
| # | Task | Done when | Points | Budget |
|---|---|---|---|---|
| 5.1 | Re-point promo-banner at registry.internal/promo-banner, pinned by digest instead of a tag. | kubectl get deploy promo-banner -o jsonpath='{..image}' shows a registry.internal/promo-banner@sha256:… reference; Pod Running. | 6 | 6 min |
| 5.2 | Scan the new image with trivy, confirm there's a CRITICAL CVE, and re-point the Deployment at the already-patched digest published on the same registry. | trivy image --severity CRITICAL against the final digest reports zero CRITICAL findings. | 7 | 8 min |
| 5.3 | Apply supply-chain-gate, then confirm it rejects an unsigned image from an unapproved registry while admitting promo-banner's current, signed image. | A test Pod referencing docker.io/anything:latest is rejected by admission; the current promo-banner Pod stays Running. | 7 | 8 min |
Worked solution — Domain 5
5.1 — Approved registry, pinned by digest. A mutable :latest tag on an unapproved public registry fails two rules at once — where it came from, and whether what's running today is provably the same as what was scanned:
crane digest registry.internal/promo-banner:2.1
# sha256:7ac0e4d2...
kubectl set image deployment/promo-banner -n ops \
promo-banner=registry.internal/promo-banner@sha256:7ac0e4d2...
kubectl rollout status deployment/promo-banner -n ops
kubectl get deploy promo-banner -n ops -o jsonpath='{.spec.template.spec.containers[0].image}'
# registry.internal/promo-banner@sha256:7ac0e4d2...5.2 — Scan, find the CVE, move to the patched digest. A digest pin only proves reproducibility, not safety — it still has to actually get scanned:
trivy image --severity CRITICAL registry.internal/promo-banner@sha256:7ac0e4d2...
# Total: 1 (CRITICAL: 1)
# CVE-2025-XXXX libxml2 CRITICAL 2.9.14-r0 fixed in 2.9.14-r1# the fixed build is already published — repoint rather than rebuild during the exam
kubectl set image deployment/promo-banner -n ops \
promo-banner=registry.internal/promo-banner@sha256:c19f88ab... # the patched digest
trivy image --severity CRITICAL registry.internal/promo-banner@sha256:c19f88ab...
# Total: 0 (CRITICAL: 0)5.3 — The admission gate that actually enforces it. Neither the scan nor the digest pin matters at all if nothing at admission time checks for them — this is the last mile, and the one people most often skip because it feels like "someone else's policy engine":
kubectl apply -f /root/policies/supply-chain-gate.yaml
kubectl get clusterpolicy supply-chain-gatekubectl run rogue --image=docker.io/anything:latest -n ops
# Error from server: admission webhook "validate.kyverno.svc-fail" denied the request:
# Images must come from registry.internal
kubectl get pod promo-banner -n ops
# still Running — its image already satisfies both rulesMore depth: the CKS blueprint's Supply Chain Security section, the sibling DevSecOps course's Trivy, cosign, and Kyverno guides, plus the sibling Golden Astronaut course, where Kyverno and OPA Gatekeeper are full project certifications in their own right rather than a light exam-adjacent mention.
Domain 6 — Monitoring, Logging & Runtime Security (20 points)
☺ Like you're 10: This domain is the hallway camera and the logbook — proof of who did what, and someone actually watching the feed instead of just recording it and hoping nobody ever asks.
Scenario. Namespace ops: audit logging is not yet wired into the API server. Deployment legacy-worker is running on a mutable :latest tag with a writable root filesystem. A Falco alert has already fired and is saved at /var/log/falco-events.log on cks-mock2-w1, where legacy-worker happens to be scheduled.
| # | Task | Done when | Points | Budget |
|---|---|---|---|---|
| 6.1 | Wire audit logging into the API server: Metadata level for Secrets/ConfigMaps, RequestResponse for RBAC objects, Metadata for everything else. Make an RBAC change and find it in the log. | The audit log contains the RBAC change at level: RequestResponse. | 7 | 7 min |
| 6.2 | Read the Falco alert in /var/log/falco-events.log, name which phase of an attack it represents, and respond: remove the compromised legacy-worker Pod and quarantine anything still carrying its identity. | The compromised Pod is gone and replaced by a clean one; a one-line note correctly names the attack phase; a NetworkPolicy blocks the old Pod's label from any traffic. | 7 | 8 min |
| 6.3 | Make legacy-worker immutable at runtime: read-only root filesystem, pinned by digest instead of :latest. | The Pod is Running with readOnlyRootFilesystem: true and an image reference pinned by digest. | 6 | 6 min |
Worked solution — Domain 6
6.1 — Audit policy, first-match-wins. Rules are evaluated top to bottom and the first matching rule decides the level — Secrets and ConfigMaps have to come before the catch-all, or the catch-all's broader level would apply to them first:
apiVersion: audit.k8s.io/v1
kind: Policy
omitStages: ["RequestReceived"]
rules:
- level: Metadata # who touched it, never the payload
resources: [{ group: "", resources: ["secrets", "configmaps"] }]
- level: RequestResponse # RBAC changes are the classic escalation path — keep the whole object
resources: [{ group: "rbac.authorization.k8s.io", resources: ["roles", "rolebindings", "clusterroles", "clusterrolebindings"] }]
- level: Metadata # everything else in the clustersudo mkdir -p /etc/kubernetes/audit /var/log/kubernetes/audit
sudo cp audit-policy.yaml /etc/kubernetes/audit/policy.yaml
sudo vi /etc/kubernetes/manifests/kube-apiserver.yaml
# command:
# - --audit-policy-file=/etc/kubernetes/audit/policy.yaml
# - --audit-log-path=/var/log/kubernetes/audit/audit.log
# volumeMounts:
# - { name: audit-policy, mountPath: /etc/kubernetes/audit, readOnly: true }
# - { name: audit-log, mountPath: /var/log/kubernetes/audit }
# volumes:
# - { name: audit-policy, hostPath: { path: /etc/kubernetes/audit, type: DirectoryOrCreate } }
# - { name: audit-log, hostPath: { path: /var/log/kubernetes/audit, type: DirectoryOrCreate } }kubectl create role test-role -n ops --verb=get --resource=pods
grep '"resource":"roles"' /var/log/kubernetes/audit/audit.log | tail -1 | grep -o '"level":"[^"]*"'
# "level":"RequestResponse"6.2 — Read the alert, name the phase, respond. A shell spawned unexpectedly inside a running container is post-compromise activity — execution, not initial access — because something already had to get code running before it could open a shell at all:
cat /var/log/falco-events.log
# Warning A shell was spawned in a container with an attached terminal
# (user=root container=legacy-worker-7f2... command=bash image=legacy-worker:latest)# note: "Execution" phase — an interactive shell inside an already-running container,
# not reconnaissance or initial access, which would happen before the container existed
kubectl delete pod -n ops -l app=legacy-worker
kubectl rollout restart deployment legacy-worker -n opsapiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: quarantine-legacy-worker-old, namespace: ops }
spec:
podSelector:
matchLabels: { app: legacy-worker, incident: 2026-08-27 }
policyTypes: [Ingress, Egress]
# no rules — anything still carrying the flagged identity gets fully cut offkubectl get pods -n ops -l app=legacy-worker
# a fresh Pod, 0 restarts, not the one Falco flagged6.3 — Immutability, not a live patch. The instinct to kubectl exec in and fix a running container is exactly backwards here — treat anything already flagged as compromised, and treat the fix itself as "replace the image," never "edit the container":
crane digest registry.internal/legacy-worker:2.0
# sha256:4b81cd90...spec:
template:
spec:
containers:
- name: legacy-worker
image: registry.internal/legacy-worker@sha256:4b81cd90...
securityContext:
readOnlyRootFilesystem: true
volumeMounts:
- { name: scratch, mountPath: /var/run/app }
volumes:
- name: scratch
emptyDir: {}kubectl apply -f legacy-worker-deployment.yaml
kubectl get pods -n ops -l app=legacy-worker
# Running, 0 restarts — the app's one writable path now lives on the emptyDir, not the image layerMore depth: the CKS blueprint's Monitoring, Logging and Runtime Security section, the sibling DevSecOps course's Falco guide and Kubernetes Security Deep Dive, hands-on in the RBAC-hardening drill.
Score yourself
☺ Like you're 10: Add up your points, compare to the pass line, then look at which questions cost you — that second part is the part that actually helps next time.
Mark after a short break, not immediately — adrenaline makes for generous self-grading. Award full points only when the done-when check genuinely passed on your own cluster, half when the object exists and is broadly right but the check didn't pass clean, and zero for anything unattempted (including tasks 3.2 or 4.3 if your lab genuinely lacked AppArmor or runsc — mark those "not attempted, no lab" separately rather than folding them into a real zero).
| # | Task | Points | Your score |
|---|---|---|---|
| 1.1 | D1 — default-deny + scoped allow + metadata check | 6 | |
| 1.2 | D1 — TLS Secret + Ingress | 5 | |
| 1.3 | D1 — kube-bench finding, fixed | 4 | |
| 2.1 | D2 — cluster-admin binding removed, scoped RBAC in place | 6 | |
| 2.2 | D2 — automount disabled, verified | 4 | |
| 2.3 | D2 — anonymous auth disabled, verified | 5 | |
| 3.1 | D3 — seccomp RuntimeDefault | 5 | |
| 3.2 | D3 — AppArmor profile enforced | 5 | |
| 4.1 | D4 — restricted PSA, Pod compliant | 7 | |
| 4.2 | D4 — Secret extracted, RBAC scoped | 6 | |
| 4.3 | D4 — gVisor RuntimeClass | 7 | |
| 5.1 | D5 — approved registry, digest-pinned | 6 | |
| 5.2 | D5 — CVE found, patched digest | 7 | |
| 5.3 | D5 — supply-chain gate enforced | 7 | |
| 6.1 | D6 — audit policy wired, verified | 7 | |
| 6.2 | D6 — incident diagnosed and contained | 7 | |
| 6.3 | D6 — runtime immutability enforced | 6 | |
| Total | All 6 domains | 100 |
Computing your result. The points total 100, so your raw score is your percentage. The CKS's pass mark, like the CKA's, has generally been reported in the mid-60s — confirm the current figure before treating it as gospel. Then do the more useful arithmetic and total each domain separately, because two candidates can post the same overall score in completely different shape — one with a shallow, even gap across all six domains, the other with one domain at zero and the rest close to perfect. The second candidate has a specific, fixable hole; the first has a longer list of small ones.
| Domain | Available | Yours | If you scored under two-thirds, go here |
|---|---|---|---|
| Cluster Setup | 15 | CKS blueprint, networking & the CNI, then Practice Tasks. | |
| Cluster Hardening | 15 | RBAC & Admission Control, then the RBAC-hardening drill. | |
| System Hardening | 10 | Security: Defense in Depth, then DevSecOps's Kubernetes Security Deep Dive. | |
| Minimize Microservice Vulnerabilities | 20 | RBAC & Admission Control (PSA), then Capstone Part 5. | |
| Supply Chain Security | 20 | Trivy, cosign, Kyverno. | |
| Monitoring, Logging & Runtime Security | 20 | Falco, then Practice Tasks. |
What each miss means
☺ Like you're 10: There are three different reasons to lose points, and each one needs a different fix — guessing the wrong one wastes a week of studying the wrong thing.
Sort your zeros and halves into three piles before deciding what to re-study. A task you didn't recognize the shape of at all — like not knowing AppArmor uses an annotation rather than a securityContext field — is a real knowledge gap: go read the linked domain page, then redo that exact task cold in two or three days. A task where you knew roughly what to do but burned the clock hunting for a flag or a field name is a speed problem, and the fix is the kubectl fluency baseline and repetition, not another read of the blueprint. A task that broke in a way you couldn't diagnose — an admission rejection whose message you didn't fully parse, evidence that didn't point anywhere — is a triage gap, and the fix is working a troubleshooting methodology deliberately, evidence before guesses. One pattern sits outside all three: count how many tasks you left completely untouched. More than two or three, and the honest diagnosis is pacing, not knowledge — points you never got the chance to contest.
You've now read every solution on this paper. A strong score tells you that you can execute this specific material under a clock — genuinely useful, but not the same claim as "I'm ready for the real exam." If you haven't sat Set 1 yet, that's your next unseen rep; once both are behind you, rotate through CKS Practice Tasks for fresh individual reps rather than re-reading either paper's solutions.
Foxy: 68. Passed, technically — but I lost all seven points on 6.2 because I couldn't tell if that Falco alert was "initial access" or "execution."
Ellie: A shell spawned inside an already-running container can't be initial access, Foxy — something had to be running first for a shell to spawn inside it. That's execution, or later.
Gizmo: Eh, semantics. Just kubectl exec in, kill the shell process, and call the incident closed. Way faster than deleting and redeploying the whole Pod! 🤑
Timmy: Killing one process inside a container you already know is compromised proves nothing — you have no idea what else that shell already did before you found it. Replace the Pod from a clean, digest-pinned image. Anything less is hoping, not fixing.
Remy: And this is exactly what the eight-minute budget on 6.2 is for — enough time to read the alert once, name the phase, and act, but not enough to second-guess yourself into a live patch.
Professor Owl: Sit that one task again tomorrow, Foxy, with a fresh made-up alert line. If you can name the phase in under a minute this time, the gap wasn't Falco — it was just never having done it once under a clock before.
After this sitting
☺ Like you're 10: Only re-practice the parts you actually got wrong, and go find a second opinion on the parts that still feel shaky.
Resist re-drilling everything — the entire value of a scored, weighted paper is telling you where not to spend your next week. Take your one or two weakest domains and work only those sections of CKS Practice Tasks, plus the matching capstone part or drill, and leave the domains you already cleared alone. If you haven't sat Set 1 yet, that's the unseen paper to go sit next — this course keeps only these two CKS papers, unlike the CKA's three, so once both are behind you, Practice Tasks is where your remaining reps live. For the logistics of the actual sitting, this course's exam-day page covers registration and the proctoring environment, and the study plan's resource table has second opinions on the same exam from Platform Engineering, SRE, and DevSecOps. Once CKA, CKAD and CKS are all behind you, the wider Kubestronaut and Golden Kubestronaut ladder — the other nine CNCF certifications plus the LFCS — lives in the sibling Golden Astronaut course.
This page is an independent, unofficial study resource — not affiliated with the CNCF or the Linux Foundation. The pass mark, the roughly-two-hour duration, the six domain weights, the permitted-documentation allowlist, and the curriculum version all change over time. Confirm current details on the official Linux Foundation CKS page and the CNCF certification page before you pay for anything, and read the Candidate Handbook in your LF portal. The curriculum itself is published openly at github.com/cncf/curriculum.
1. Why does task 1.1's done-when check for the cloud metadata endpoint need no extra NetworkPolicy rule of its own? 2. In task 2.3, what's the practical difference between a 403 and a 401 from the API server on an unauthenticated request, and why does it matter here? 3. In task 3.2, what's the one thing about AppArmor's configuration mechanism that differs from every other securityContext-based control on this paper? 4. In task 4.3, why does the Pod need to be pinned to cks-mock2-w2 specifically rather than left for the scheduler to place? 5. In task 6.2, why is redeploying from a clean image the correct response instead of killing the shell process inside the existing container? 6. You score 70, but all 20 missed points came from a single domain — what does that tell you that a 70 spread evenly across all six wouldn't?
Check your answers
- Because a proper default-deny-egress NetworkPolicy already blocks all outbound traffic by default, including to the metadata endpoint — the task only asks you to prove that's true, not add a rule specifically targeting
169.254.169.254. - A
403 Forbiddenmeans the request was still assigned an identity (system:anonymous) and then denied by RBAC — proof anonymous auth is still active. A401 Unauthorizedmeans no identity was assigned at all, which is the correct behavior once--anonymous-auth=falseis actually in effect. - AppArmor profiles are referenced through a Pod-level annotation (
container.apparmor.security.beta.kubernetes.io/<container>), not asecurityContextfield like seccomp, capabilities, orrunAsNonRoot— every other control on this paper lives undersecurityContext. - Because the
runscruntime handler is only configured in containerd oncks-mock2-w2— a Pod withruntimeClassName: gvisorscheduled anywhere else would fail to start with no matching runtime handler, so pinning the node is what makes the task actually verifiable. - Because killing one process only removes what you happened to notice — it proves nothing about what else already ran inside that container before you found it. Treating the whole container as compromised and replacing it from a known-clean, digest-pinned image is the only response that doesn't rely on hoping you caught everything.
- That you have one concentrated blind spot rather than a shallow, even gap. Two candidates can post the identical 70, but the one who lost every point in a single domain has a specific, fixable hole — and a task draw that leans even slightly harder into that same domain is genuinely riskier for them than for a candidate with the identical score spread evenly.
That's the paper. Build the lab cluster yourself, start one timer, and let the seventeen tasks above take you through two hours that should feel noticeably less comfortable than working through the blueprint untimed. Score it honestly, note your weakest domain, and spend your next study block exactly there — then head back to Set 1 if you haven't sat it yet, or to Kubernetes Certifications to plan the rest of the ladder.