CKS Mock Exam · Set 1
This is a full, timed sitting of the CKS in miniature: fifteen performance-based hardening tasks against live clusters, one unbroken 120-minute clock, and nothing graded except the cluster state you leave behind — a fix you believe is correct but never applied scores exactly the same as no fix at all. Set 1 tracks the CNCF's own domain weights from curriculum v1.34 as closely as fifteen whole-number tasks allow: 15 points each on Cluster Setup and Cluster Hardening, 10 on System Hardening, and 20 each on Minimize Microservice Vulnerabilities, Supply Chain Security, and Monitoring, Logging & Runtime Security — the same 15/15/10/20/20/20 split documented on the CKS blueprint page. That means a good score here is a genuine read on exam readiness, not just a score on this one paper. Every task carries an objective done when check and a worked solution folded away until you've actually attempted it — mark yourself honestly, total the sheet against the commonly cited 67% pass mark, and let the domain breakdown, not the raw number, choose what you study next.
Imagine an airport security checkpoint built from six different stations, and the checkpoint's own scorecard doesn't weight them evenly, because a real one never does. Two medium stations check the building itself — who's allowed through which door, and whether the walls and locks are actually sound. One small station checks the guards' own equipment. Three big stations do the real work: patting down every passenger for something dangerous, x-raying every bag before it's allowed anywhere near a plane, and watching every camera afterward for someone acting strange once they're already inside. A fair practice run gives you a big station worth a fifth of your grade and a small station worth a tenth, in the same proportions the real test uses — so getting good at this practice run means something on the actual day. That's this paper: six stations, unequal in size on purpose, matching the real curriculum's own sizes instead of a made-up ideal.
Before you start — exam conditions
☺ Like you're 10: A practice fire drill only teaches you something if you run it like the real one — one timer, no peeking, nobody helping.
Build a disposable multi-node cluster beforehand — kind or minikube with at least 4 CPUs and 6GB of RAM covers everything on this paper except SH1 and SH2, which need seccomp and AppArmor support the host kernel actually provides — most Linux hosts ship both, but check before you commit the minutes. Install a CNI that actually enforces NetworkPolicy — Cilium or Calico, never the default bridge alone — since CS2 and several later tasks depend on policy actually being enforced, not merely accepted by the API server. Have kube-bench, trivy and cosign already on PATH, and Falco already running as a DaemonSet with its default ruleset; SC1 additionally assumes Kyverno is already installed cluster-wide, exactly the way the real exam pre-installs whichever policy engine a given task needs rather than asking you to install one under the clock. Start one 120-minute timer and don't pause it. Keep only kubernetes.io/docs open in a second tab — the real CKS's permitted-documentation allowlist runs wider than the CKA's but is still finite, so build the habit of checking only what you'd actually be allowed to check, not whatever's fastest. Read all fifteen tasks first; five minutes spent doing that is the highest-return five minutes of the sitting, because several tasks here reuse the same namespace or the same running Pod, and knowing that before you start changes the order you'd sanely attempt them in.
When a task passes its budgeted minutes below with no passing done-when check, stop, write one line about where you stalled, leave your partial work exactly as it is, and move to the next task. CKS's task list skews toward "find and close a real hole" rather than "build this correctly from a blank file," which makes it tempting to keep digging on a task where you're certain the vulnerability is somewhere in the object you're staring at — but the real exam pays the same zero for a hole you found and ran out of time to close as for one you never found at all. Timmy's rule, unchanged from every other paper in this course: you are not paid to finish tasks, you are paid to bank points.
Your time budget and domain weights
☺ Like you're 10: The clock gets sliced up the same way the real exam's score does — the three biggest slices go to microservice vulnerabilities, supply chain, and runtime monitoring, not to whichever task looks hardest.
Compare this table with the same six percentages on the CKS exam page: 15/15/10/20/20/20, summing to exactly 100, in the curriculum's own domain order. Minutes below are sized to match points one-for-one — a small piece of exam craft worth internalizing on its own: a task worth twice as many points as another should get roughly twice the time, never an equal share just because there are fifteen boxes on the sheet.
| Domain | Tasks | Points | Share of paper | Minutes |
|---|---|---|---|---|
| 🐦 Cluster Setup | CS1–CS2 | 15 | 15% | 15 |
| 🐢 Cluster Hardening | CH1–CH2 | 15 | 15% | 15 |
| 🦉 System Hardening | SH1–SH2 | 10 | 10% | 10 |
| 🔑 Minimize Microservice Vulnerabilities | MV1–MV3 | 20 | 20% | 20 |
| 🦫 Supply Chain Security | SC1–SC3 | 20 | 20% | 20 |
| 🐘 Monitoring, Logging & Runtime Security | MR1–MR3 | 20 | 20% | 20 |
| Total | 15 tasks | 100 | 100% | 100 + 5 read + 15 verify |
Cluster Setup — CS1 to CS2 (15 points)
☺ Like you're 10: This block is "make sure the building's own bones are sound" — the guards' equipment actually works, and nobody outside can quietly walk off with the building's spare key.
Background: the CKS blueprint's Cluster Setup section and Security: Defense in Depth.
CS1 · Close two failing CIS benchmark checks on a live kubelet (8 pts)
Node worker-01 fails two kube-bench checks in the node category: the kubelet's --anonymous-auth is not disabled, and its authorization mode is AlwaysAllow instead of Webhook. Fix the kubelet's own config file (not a command-line flag) and restart the service so both checks pass.
Done when: kube-bench run --targets=node shows [PASS] for the anonymous-auth and authorization-mode checks, and curl -sk https://localhost:10250/pods run on worker-01 without a bearer token now returns 401 Unauthorized instead of a Pod list.
Show the worked solution
# /var/lib/kubelet/config.yaml on worker-01 — add or fix these two blocks
authentication:
anonymous:
enabled: false
authorization:
mode: Webhooksudo systemctl restart kubelet sudo systemctl status kubelet --no-pager | head -5 kube-bench run --targets=node | grep -A1 "anonymous-auth\|authorization-mode" curl -sk https://localhost:10250/pods # now 401, was 200
Why: these are CIS Kubernetes Benchmark checks 4.2.1 and 4.2.2, and they're paired for a reason — anonymous requests to the kubelet API are, by default, treated as the system:anonymous user, and AlwaysAllow authorizes literally anything that user asks for, including listing every Pod on the node or exec-ing into one. Fixing only one of the two still leaves a hole: disable anonymous auth but leave AlwaysAllow, and any request that does present some token — even a garbage one accepted as anonymous under certain configurations — still sails through authorization unchecked. The fix lives in the kubelet's own config file rather than a systemd flag because that's where kubeadm-managed clusters expect it, and because a file is what kube-bench itself parses to render its verdict.
CS2 · Block every Pod's path to the cloud metadata endpoint (7 pts)
Namespace storefront runs Pods with no restriction on their egress destinations. Any of them can currently reach the cloud instance metadata service at 169.254.169.254 — which, on this cluster's nodes, would hand a compromised Pod the node's own cloud IAM credentials. Block that one destination for every Pod in the namespace, without breaking DNS resolution or normal outbound internet access.
Done when: from a test Pod in storefront, curl -sm3 http://169.254.169.254/latest/meta-data/ times out, while nslookup kubernetes.default.svc.cluster.local and a normal outbound request such as curl -sm3 https://kubernetes.default.svc both still succeed.
Show the worked solution
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: allow-dns, namespace: storefront }
spec:
podSelector: {}
policyTypes: [Egress]
egress:
- to:
- namespaceSelector: {}
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: block-metadata-endpoint, namespace: storefront }
spec:
podSelector: {}
policyTypes: [Egress]
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0
except: ["169.254.169.254/32"]Why: a NetworkPolicy can't express "allow everything except this one address" in a single ipBlock unless the exception is attached to the CIDR that would otherwise include it — except only works inside an ipBlock entry, carved out of that entry's own cidr. Splitting DNS into its own rule matters because 0.0.0.0/0 is an IP-only match — it says nothing about in-cluster Service traffic to CoreDNS, which typically isn't reached over a plain IP block the way external internet egress is; giving DNS its own explicit to selector keeps name resolution working regardless of how the CNI represents in-cluster destinations. This is, almost verbatim, the Cluster Setup competency named "protect node metadata and endpoints" from the CKS blueprint — it exists because cloud IAM credentials reachable from inside a Pod are one of the most common real-world routes from "we got code execution in a container" to "we own the AWS account."
Cluster Hardening — CH1 to CH2 (15 points)
☺ Like you're 10: This block is "don't hand out a master key by accident" — neither to a Pod that never needed one, nor to a stranger who never even knocked.
Background: RBAC & Admission Control and the RBAC-hardening drill.
CH1 · Disable the default ServiceAccount token, then scope one that's actually needed (8 pts)
Namespace storefront has several Deployments whose Pods automatically mount the default ServiceAccount's token, even though none of them call the Kubernetes API. Turn that off at the namespace's default, then create a scoped ServiceAccount config-watcher with a narrow Role for the one Deployment, config-watcher, that genuinely needs to watch ConfigMaps — and wire only that Deployment's Pod template to mount a token again.
Done when: kubectl get sa default -n storefront -o jsonpath='{.automountServiceAccountToken}' prints false; Pods belonging to every other Deployment in storefront have no /var/run/secrets/kubernetes.io/serviceaccount/token; the config-watcher Deployment's Pods use ServiceAccount config-watcher and can watch ConfigMaps only inside storefront.
Show the worked solution
kubectl patch sa default -n storefront -p '{"automountServiceAccountToken": false}'apiVersion: v1
kind: ServiceAccount
metadata: { name: config-watcher, namespace: storefront }
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: configmap-watcher, namespace: storefront }
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { name: configmap-watcher-binding, namespace: storefront }
subjects:
- kind: ServiceAccount
name: config-watcher
namespace: storefront
roleRef: { kind: Role, name: configmap-watcher, apiGroup: rbac.authorization.k8s.io }# patch onto the config-watcher Deployment's pod template spec: serviceAccountName: config-watcher automountServiceAccountToken: true # overrides the SA-level false — this Pod is the exception
Why: automountServiceAccountToken can be set at the ServiceAccount level, the Pod level, or both — the Pod-level value always wins when both are present, which is exactly the mechanism this task depends on. Setting it false on default closes the hole for every Pod that never opted into a specific identity; setting it explicitly true on the one Pod template that genuinely needs API access re-opens it there alone, without touching any other Deployment. A mounted token an application never uses is pure downside — it's a bearer credential sitting in the container filesystem, readable by anything that achieves code execution inside it, authorizing nothing the app actually needed.
CH2 · Find and remove a ClusterRoleBinding granting cluster-admin to anonymous requests (7 pts)
Somewhere on this cluster, a ClusterRoleBinding named legacy-anon-admin binds the group system:unauthenticated — which every request the API server can't authenticate falls into, including system:anonymous — to cluster-admin. Find it and remove it.
Done when: kubectl get clusterrolebindings -o json | jq -r '.items[] | select(.subjects[]?.name=="system:unauthenticated") | .metadata.name' returns nothing, and kubectl auth can-i '*' '*' -A --as=system:anonymous returns no.
Show the worked solution
kubectl auth can-i '*' '*' -A --as=system:anonymous # yes — confirms the hole before touching anything kubectl get clusterrolebindings -o json \ | jq -r '.items[] | select(.subjects[]?.name=="system:unauthenticated") | .metadata.name' # legacy-anon-admin kubectl delete clusterrolebinding legacy-anon-admin kubectl auth can-i '*' '*' -A --as=system:anonymous # no
Why: a ClusterRoleBinding to cluster-admin is already the single most dangerous object type in a cluster's RBAC graph — binding it to system:unauthenticated means anyone who can reach the API server's network endpoint at all, with no credential whatsoever, is a full cluster administrator. This exact misconfiguration is CIS Benchmark territory too — "ensure that the cluster-admin role is only used where required" — and it's a real pattern seen in the wild, usually left over from an early demo or a debugging session where someone bound cluster-admin broadly to stop fighting RBAC and never removed it once the debugging was done. kubectl auth can-i --as re-runs the real authorization check by impersonation before and after, which is the fastest way to prove the fix actually closed the hole rather than just deleting an object that looked suspicious.
System Hardening — SH1 to SH2 (10 points)
☺ Like you're 10: This is the smallest block on the whole paper — but it's the layer underneath the kubelet itself, where the Linux kernel decides what a container is even allowed to try.
Background: the CKS blueprint's System Hardening section and DevSecOps's Kubernetes Security Deep Dive for the full defense-in-depth layering these two kernel tools sit inside.
SH1 · A seccomp profile that blocks container-escape-adjacent syscalls (5 pts)
Deployment doc-processor in namespace ops currently runs with the default seccomp profile (effectively unconfined on this cluster's runtime). Write a custom seccomp profile that blocks mount, umount2, ptrace and keyctl — syscalls a normal document-processing workload never needs and several container-escape techniques rely on — while leaving everything else the app actually uses untouched.
Done when: the Pod is Running; kubectl exec into it and attempting mount -t tmpfs tmpfs /mnt fails with Operation not permitted; the application's normal file read/write still works.
Show the worked solution
{
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": [
{
"names": ["mount", "umount2", "ptrace", "keyctl"],
"action": "SCMP_ACT_ERRNO"
}
]
}
spec:
template:
spec:
securityContext:
seccompProfile:
type: Localhost
localhostProfile: profiles/doc-processor-audit.jsonkubectl exec deploy/doc-processor -n ops -- mount -t tmpfs tmpfs /mnt # mount: permission denied (are you root?) — blocked by seccomp, not by a missing capability
Why: localhostProfile is a path relative to the kubelet's configured seccomp root, which defaults to /var/lib/kubelet/seccomp/ — the profile has to exist at that exact path on every node the Pod could be scheduled to, which is the detail most candidates miss under time pressure. A defaultAction: SCMP_ACT_ALLOW profile that denies a short, specific list is the safer starting shape for an application you can't fully audit — an allow-list profile (SCMP_ACT_ERRNO default, explicit allows) is stricter but breaks in surprising ways the moment the app calls one syscall nobody thought to allow. Blocking mount and ptrace specifically matters because both are common levers in real container-breakout techniques — mount can expose host paths, ptrace can attach to and manipulate another process's memory — and a normal document-processing workload has no legitimate reason to call either.
SH2 · An AppArmor profile that denies writes outside the app's own directory (5 pts)
Pod audit-agent in namespace ops should never write anywhere on its filesystem except its own /var/lib/audit-agent data directory. Load an AppArmor profile enforcing that on the node, and confine the Pod to it.
Done when: kubectl exec audit-agent -n ops -- sh -c 'echo x > /etc/test' fails with Permission denied, while kubectl exec audit-agent -n ops -- sh -c 'echo x > /var/lib/audit-agent/probe' succeeds.
Show the worked solution
# /etc/apparmor.d/k8s-deny-write-outside-audit-agent on every candidate node
cat <<'EOF' | sudo tee /etc/apparmor.d/k8s-deny-write-outside-audit-agent
#include <tunables/global>
profile k8s-deny-write-outside-audit-agent flags=(attach_disconnected) {
#include <abstractions/base>
file,
/var/lib/audit-agent/** rw,
deny /etc/** w,
deny /root/** w,
}
EOF
sudo apparmor_parser -r -W /etc/apparmor.d/k8s-deny-write-outside-audit-agentspec:
containers:
- name: agent
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: k8s-deny-write-outside-audit-agentWhy: unlike a seccomp localhostProfile, which is a file path, an AppArmor localhostProfile is the profile's own name as declared inside the file's profile <name> { ... } line — the two fields look similar but reference different things, and mismatching them is the single most common way this task fails silently, with the container simply running unconfined instead of erroring. AppArmor works at the path level rather than the syscall level, which is exactly the right tool here: seccomp in SH1 restricts which operations a process may attempt at all, while AppArmor restricts which paths an otherwise-permitted operation like write() may target — the two are complementary, not redundant, which is why the curriculum names both by name rather than treating "kernel hardening" as one interchangeable line item.
"I don't write YAML for a living, so the first time I watched someone apply a seccomp profile I assumed it was security theater — surely an attacker who's already inside a container just works around it? Then I watched the same demo where mount got blocked mid-exploit, and the whole privilege-escalation chain the presenter was walking through just... stopped, one step in. It's not that the kernel-level stuff is more important than RBAC or admission — it's that it's the layer that still holds even after every layer above it has already failed. That's a genuinely different guarantee than 'we configured this correctly,' and it's the one I trust most."
Minimize Microservice Vulnerabilities — MV1 to MV3 (20 points)
☺ Like you're 10: This block is "even if a room gets broken into, keep it a small, boring, one-room problem" — a Pod that can't ask for more than it's allowed, a secret that isn't just sitting there in plain text, and a truly untrusted job kept in a room with extra-thick walls.
Background: the CKS blueprint's Minimize Microservice Vulnerabilities section, which has the fully worked Pod Security Admission example this domain is built around, and RBAC & Admission Control for where PSA sits in the admission pipeline.
MV1 · Enforce Pod Security Admission "restricted", then fix a Pod to clear it (7 pts)
Namespace payments needs the restricted Pod Security Standard enforced. A manifest for Pod legacy-worker — privileged, running as root, with no capabilities dropped — is submitted next; it must be rejected. Fix the manifest so it satisfies restricted and get it running under a new name, legacy-worker-hardened.
Done when: applying the original privileged manifest against payments fails with violates PodSecurity "restricted:latest", and legacy-worker-hardened reaches Running in the same namespace.
Show the worked solution
kubectl label ns payments \ pod-security.kubernetes.io/enforce=restricted \ pod-security.kubernetes.io/enforce-version=latest
apiVersion: v1
kind: Pod
metadata: { name: legacy-worker-hardened, namespace: payments }
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 10001
seccompProfile: { type: RuntimeDefault }
containers:
- name: worker
image: myorg/legacy-worker:2.3.0
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
privileged: false
capabilities: { drop: ["ALL"] }Why: the restricted Pod Security Standard rejects at admission time, before the object is ever persisted — the error message names every field it found missing or wrong, which is deliberately how the CKS blueprint's worked example teaches this: apply the broken version first and read the rejection like a checklist rather than guessing at the fix from memory. Every field in the corrected manifest is load-bearing — drop runAsNonRoot and restricted rejects it for potentially running as root; drop the container-level capabilities.drop: [ALL] and it rejects it for retaining default Linux capabilities the standard doesn't allow.
MV2 · Turn on etcd encryption at rest for Secrets (7 pts)
This cluster's Secrets are currently stored in etcd exactly as the API server received them — base64, not encrypted. Configure envelope encryption with the aescbc provider so Secrets are encrypted at rest going forward, then re-write the existing Secret ledger-creds in namespace ledger so it gets encrypted under the new configuration, and confirm directly against etcd — not through the API server, which would just decrypt it back for you.
Done when: reading /registry/secrets/ledger/ledger-creds straight out of etcd shows a value prefixed k8s:enc:aescbc:v1:key1 rather than a plaintext base64 payload.
Show the worked solution
head -c 32 /dev/urandom | base64 # generates the key; call the output <KEY>
# /etc/kubernetes/enc/enc.yaml on the control-plane node
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources: ["secrets"]
providers:
- aescbc:
keys:
- { name: key1, secret: "<KEY>" }
- identity: {} # existing unencrypted secrets remain readable during the switch# /etc/kubernetes/manifests/kube-apiserver.yaml — add the flag and a hostPath mount
spec:
containers:
- command:
- kube-apiserver
- --encryption-provider-config=/etc/kubernetes/enc/enc.yaml
# ...existing flags unchanged
volumeMounts:
- { name: enc-config, mountPath: /etc/kubernetes/enc, readOnly: true }
volumes:
- { name: enc-config, hostPath: { path: /etc/kubernetes/enc } }# kubelet watches the static pod manifest dir and restarts kube-apiserver automatically kubectl get secret ledger-creds -n ledger -o json | kubectl replace -f - # re-encrypt this one now ETCDCTL_API=3 etcdctl \ --cacert=/etc/kubernetes/pki/etcd/ca.crt \ --cert=/etc/kubernetes/pki/etcd/server.crt \ --key=/etc/kubernetes/pki/etcd/server.key \ get /registry/secrets/ledger/ledger-creds | hexdump -C | head -1 # was: plain base64 payload — now starts with k8s:enc:aescbc:v1:key1
Why: a Kubernetes Secret is base64-encoded, which is an encoding, not encryption — anyone with get on the object, or direct read access to etcd, can decode it in one command. EncryptionConfiguration is the actual control, and it only affects writes going forward — an existing Secret already sitting in etcd stays exactly as it was until something re-writes it, which is why kubectl get ... -o json | kubectl replace -f - is the standard trick to force re-encryption of everything already there without changing a single field's value. The identity: {} provider listed after aescbc matters during the rollout window: it lets the API server still read Secrets that haven't been re-encrypted yet, rather than locking you out of your own cluster the moment the flag is set. Checking through etcdctl directly, rather than kubectl get secret -o yaml, is the only way to prove the data at rest actually changed — the API server would decrypt it transparently either way.
MV3 · Sandbox a genuinely untrusted workload with gVisor (6 pts)
Namespace uploads runs Pods that execute user-supplied code from an untrusted upload path. gVisor (runsc) is already installed on the node pool and registered as a containerd runtime handler. Register a RuntimeClass for it and switch Deployment code-runner to use it, so its containers run sandboxed instead of on the default runtime.
Done when: kubectl get pod -n uploads -l app=code-runner -o jsonpath='{.items[0].spec.runtimeClassName}' prints gvisor, and a shell inside the Pod shows the gVisor sandbox kernel rather than the host's — dmesg 2>&1 | head -1 begins with Starting gVisor....
Show the worked solution
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata: { name: gvisor }
handler: runsckubectl patch deploy code-runner -n uploads --type=json \
-p='[{"op":"add","path":"/spec/template/spec/runtimeClassName","value":"gvisor"}]'
kubectl rollout status deploy/code-runner -n uploads
kubectl get pod -n uploads -l app=code-runner -o jsonpath='{.items[0].spec.runtimeClassName}{"\n"}'
kubectl exec -n uploads deploy/code-runner -- dmesg 2>&1 | head -1Why: runtimeClassName is read by the kubelet when it asks the container runtime to create the Pod's sandbox — pointing it at a RuntimeClass whose handler maps to runsc in containerd's own config is what actually routes the Pod through gVisor instead of standard runc; the field does nothing on its own if that handler mapping isn't already present on the node, which is why the exam always pre-installs it rather than asking you to compile a sandbox runtime under the clock. gVisor intercepts syscalls in userspace and re-implements a large slice of the Linux kernel interface itself — so even a full container-escape exploit that would defeat runc alone still has to escape gVisor's own userspace kernel, a meaningfully higher bar for genuinely untrusted, multi-tenant code execution than seccomp or AppArmor alone provide.
Supply Chain Security — SC1 to SC3 (20 points)
☺ Like you're 10: This block asks "did this box actually come from the baker, and did anyone check what's inside it before it went upstairs" — three different checks on the thing before it's ever allowed to run.
Background: the CKS blueprint's Supply Chain Security section, the sibling DevSecOps course's Container & Supply-Chain Security, and its Trivy and Kyverno tool guides.
SC1 · An admission policy that only permits one registry (7 pts)
Kyverno is already installed cluster-wide. Write a ClusterPolicy in Enforce mode that rejects any Pod whose image is not prefixed registry.internal/, then confirm it against both a passing and a failing Pod in namespace storefront.
Done when: creating a Pod with image docker.io/library/nginx:1.27 in storefront is rejected with a message naming the policy, and one with image registry.internal/nginx:1.27 is admitted.
Show the worked solution
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata: { name: restrict-image-registries }
spec:
validationFailureAction: Enforce
rules:
- name: allowed-registries
match:
any:
- resources: { kinds: ["Pod"] }
validate:
message: "Images must come from registry.internal/"
pattern:
spec:
containers:
- image: "registry.internal/*"kubectl run bad-image -n storefront --image=docker.io/library/nginx:1.27 # Error: ... validation error: Images must come from registry.internal/ kubectl run good-image -n storefront --image=registry.internal/nginx:1.27 # pod/good-image created
Why: Kyverno's pattern validation does a structural, wildcard-aware match against the object as submitted — registry.internal/* matches any image string with that literal prefix and rejects everything else, no regular-expression engine required, which is exactly why the curriculum treats this as a recognizable shape rather than a syntax to memorize. This is the same signing/registry competency the blueprint page frames as "the last mile" of supply-chain security: scanning and signing an image upstream only matters once something at write-time is actually asked to check it, and this policy is that check. Kyverno runs as a validating (and optionally mutating) admission webhook, so this rejection happens before the object is ever persisted to etcd — an unpermitted image never gets the chance to be scheduled, let alone run.
SC2 · Scan an image, and act correctly on what Trivy finds (7 pts)
Image registry.internal/orders-api:1.9.0 is proposed for deploy. Scan it for CRITICAL severity vulnerabilities that have a known fix available; if any exist, do not deploy that tag — instead confirm that the already-published 1.9.1 tag is clean and deploy that one.
Done when: trivy image --severity CRITICAL --ignore-unfixed --exit-code 1 registry.internal/orders-api:1.9.1 exits 0, and kubectl get deploy orders-api -o jsonpath='{.spec.template.spec.containers[0].image}' ends in :1.9.1, never :1.9.0.
Show the worked solution
trivy image --severity CRITICAL --ignore-unfixed --exit-code 1 registry.internal/orders-api:1.9.0 # exit code 1 — one CRITICAL, fixed in 1.9.1: do not deploy this tag trivy image --severity CRITICAL --ignore-unfixed --exit-code 1 registry.internal/orders-api:1.9.1 # exit code 0 — clean kubectl set image deploy/orders-api orders-api=registry.internal/orders-api:1.9.1 kubectl rollout status deploy/orders-api
Why: --exit-code 1 is what turns a scan into a gate a script or a CI pipeline can actually branch on — without it, trivy prints a report and exits 0 regardless of what it found, and nothing downstream ever notices. --ignore-unfixed matters just as much on the exam as in production: a CVE with no available fix yet can't be remediated by picking a different tag, so failing a gate on it only teaches you to ignore the gate; scoping to fixable findings keeps the check actionable. As Security: Defense in Depth already frames it elsewhere in this course, a clean scan is a timestamp, not a guarantee — it answers "nothing known as of right now," which is exactly why this is one layer among six on this paper, not the whole exam by itself.
SC3 · Verify a signature before a deploy is allowed to proceed (6 pts)
All production images are signed with cosign using a key whose public half is already saved at /opt/keys/cosign.pub. Before deploying image registry.internal/billing-api:4.2.0 into namespace ledger, verify its signature; only apply the Deployment if verification succeeds.
Done when: cosign verify --key /opt/keys/cosign.pub registry.internal/billing-api:4.2.0 exits 0, and only after that success does kubectl get deploy billing-api -n ledger -o jsonpath='{.spec.template.spec.containers[0].image}' show that exact image.
Show the worked solution
cosign verify --key /opt/keys/cosign.pub registry.internal/billing-api:4.2.0
# Verification for registry.internal/billing-api:4.2.0 --
# The following checks were performed on each of these signatures:
# - The signatures were verified against the specified public key
echo $? # 0 — verification succeeded, safe to proceed
kubectl apply -f billing-api-deploy.yaml -n ledger
kubectl get deploy billing-api -n ledger -o jsonpath='{.spec.template.spec.containers[0].image}'Why: cosign verify --key checks the image manifest's attached signature against the given public key and exits non-zero the instant that check fails — an unsigned image, or one signed with a different key, never gets past this line, which is the entire point of running it as a gate rather than a curiosity before kubectl apply. Deploying by an immutable tag like :4.2.0 still leaves a narrow window where the tag could be repointed after verification; production pipelines that need to close that gap entirely resolve the tag to its digest first and verify and deploy the digest, not the tag — worth knowing as the natural next hardening step even though this task's done-when check accepts the tag form.
Monitoring, Logging & Runtime Security — MR1 to MR3 (20 points)
☺ Like you're 10: Every layer above this one tries to stop something bad from ever happening. This block assumes one of them already failed, and asks whether you'd actually notice — and whether the room stays boring even after something got in.
Background: the CKS blueprint's Monitoring, Logging and Runtime Security section, Security: Defense in Depth's full walkthrough of one exploit through every layer, and Observability on Kubernetes for the SRE-side treatment of logs and alerting this domain draws on.
MR1 · An audit policy that logs Secrets narrowly and pods/exec fully (7 pts)
Configure the API server's audit log so that any access to secrets is recorded at Metadata level only — never the payload — while every pods/exec and pods/attach call is recorded at full RequestResponse level, and everything else defaults to Metadata.
Done when: after kubectl exec -it <any-pod> -- sh, the corresponding line in /var/log/kubernetes/audit.log is RequestResponse level and includes the executed command; the corresponding line for a kubectl get secret in the same window is Metadata level and contains no data field values.
Show the worked solution
# /etc/kubernetes/audit/policy.yaml
apiVersion: audit.k8s.io/v1
kind: Policy
omitStages: ["RequestReceived"]
rules:
- level: Metadata
resources:
- { group: "", resources: ["secrets"] }
- level: RequestResponse
resources:
- { group: "", resources: ["pods/exec", "pods/attach"] }
- level: Metadata# /etc/kubernetes/manifests/kube-apiserver.yaml — add flags + hostPath mounts
spec:
containers:
- command:
- kube-apiserver
- --audit-policy-file=/etc/kubernetes/audit/policy.yaml
- --audit-log-path=/var/log/kubernetes/audit.log
- --audit-log-maxage=7
- --audit-log-maxbackup=3
volumeMounts:
- { name: audit-policy, mountPath: /etc/kubernetes/audit, readOnly: true }
- { name: audit-log, mountPath: /var/log/kubernetes }
volumes:
- { name: audit-policy, hostPath: { path: /etc/kubernetes/audit, type: DirectoryOrCreate } }
- { name: audit-log, hostPath: { path: /var/log/kubernetes, type: DirectoryOrCreate } }kubectl exec -it -n storefront deploy/checkout -- sh -c 'echo test' sleep 1 grep '"verb":"create".*pods/exec' /var/log/kubernetes/audit.log | tail -1 | jq .level # "RequestResponse" kubectl get secret ledger-creds -n ledger grep '"resource":"secrets"' /var/log/kubernetes/audit.log | tail -1 | jq '.level, .responseObject' # "Metadata" # null -- no payload logged
Why: audit Policy rules are evaluated first match wins, in list order — which is why the narrower secrets and pods/exec/pods/attach rules have to come before the catch-all Metadata rule at the bottom, not after it; reverse the order and the catch-all rule claims every request first and the specific rules never fire. Logging Secrets at only Metadata is a deliberate, security-relevant choice, not a shortcut — RequestResponse on secrets would write every Secret's plaintext value straight into the audit log, turning your intrusion-detection tool into the very data leak you're trying to catch. pods/exec and pods/attach get the opposite treatment because they're the single highest-value signal for "someone got an interactive shell inside a running container" — the full request/response, including the command that was run, is exactly what an investigator needs later.
MR2 · A Falco rule scoped to one namespace, proven live (7 pts)
Falco is already running with its default ruleset, which already alerts on "a shell spawned in a container" cluster-wide. Namespace payments needs a second, more specific rule: fire whenever a shell is spawned inside any container in payments specifically, tagged so it's distinguishable from the generic default-ruleset alert. Load it, then trigger it for real.
Done when: after kubectl exec -it <a-payments-pod> -- sh, the Falco Pod's logs contain a line from your custom rule — not just the generic default-ruleset one — naming the Pod and namespace.
Show the worked solution
# /etc/falco/rules.d/payments_shell.yaml — mounted into the Falco DaemonSet via ConfigMap
- rule: Shell Spawned in Payments Namespace
desc: Detect any interactive shell spawned inside the payments namespace specifically
condition: >
spawned_process and shell_procs and container
and k8s.ns.name = "payments"
output: >
Shell spawned in payments namespace
(user=%user.name container=%container.name pod=%k8s.pod.name command=%proc.cmdline)
priority: WARNING
tags: [payments, shell, mvp-domain]kubectl rollout restart daemonset falco -n falco # reload the mounted rules ConfigMap kubectl exec -it -n payments deploy/ledger-api -- sh -c 'echo x; exit' sleep 2 kubectl logs -n falco -l app=falco --tail=50 | grep "Shell spawned in payments namespace"
Why: Falco evaluates every loaded rule independently against the same live syscall stream, so this rule doesn't replace the default ruleset's own "Terminal shell in container" alert — both fire, and the point of scoping this one to k8s.ns.name = "payments" with its own tag is to make the payments-specific case findable in a busy log by an on-call engineer who needs to know immediately that it's the namespace holding money-moving code, not just any container anywhere. Custom rule files under /etc/falco/rules.d/ are loaded in addition to the default ruleset rather than instead of it, which is exactly the additive layering Security: Defense in Depth describes for this whole domain — narrower rules for the workloads that most need eyes on them, sitting on top of a broad baseline that watches everything else.
MR3 · Runtime immutability, plus live triage without losing state (6 pts)
Two parts. First: Deployment reporting-api in namespace ops must run with an immutable root filesystem (readOnlyRootFilesystem: true), while still being able to write its one legitimate scratch path, /tmp/reports. Second: Pod mystery-worker in the same namespace is showing unexplained high CPU. Without restarting it or losing whatever state it's holding, attach a debug container to inspect its running processes.
Done when: for reporting-api, touch /tmp/reports/probe succeeds inside the container while touch /app/probe fails with Read-only file system; for mystery-worker, an ephemeral container attaches and can list the target container's processes, and kubectl get pod mystery-worker -n ops shows an unchanged restart count throughout.
Show the worked solution
spec:
template:
spec:
containers:
- name: api
securityContext:
readOnlyRootFilesystem: true
volumeMounts:
- { name: scratch, mountPath: /tmp/reports }
volumes:
- { name: scratch, emptyDir: {} }kubectl exec deploy/reporting-api -n ops -- touch /tmp/reports/probe # OK kubectl exec deploy/reporting-api -n ops -- touch /app/probe # Read-only file system kubectl debug -it mystery-worker -n ops --image=busybox:1.36 --target=worker -- sh # inside the ephemeral container: ps aux # visible because --target shares the process namespace with the worker container
Why: readOnlyRootFilesystem is the direct, mechanical form of the curriculum's "ensure container immutability at runtime" competency — a container that can't write to its own filesystem can't be silently modified in place by an attacker who's gained a foothold, which is exactly the property runtime immutability is asking for; the one legitimate write path an app genuinely needs gets an explicit emptyDir mount instead of a filesystem-wide exception. kubectl debug --target is the investigative half of this domain: an ephemeral container joins the target container's process namespace without restarting the Pod, disturbing its existing state, or requiring shareProcessNamespace to have been set on the Pod ahead of time — precisely the property that matters when the Pod itself might be the evidence.
Score yourself
☺ Like you're 10: Add up the points, but look harder at which domain your misses cluster in — that tells you exactly what to study next, which matters more than the total.
Mark only after attempting all fifteen. Full credit only when the done-when check actually passes on your own cluster — a manifest you believe is correct but never applied, or a fix you never verified, scores nothing, exactly as the real exam grades it.
| Task | Domain | Points | Your score |
|---|---|---|---|
| CS1 · kubelet CIS benchmark fix | Cluster Setup | 8 | |
| CS2 · block the metadata endpoint | Cluster Setup | 7 | |
| CH1 · disable-default + scoped ServiceAccount | Cluster Hardening | 8 | |
| CH2 · remove anonymous cluster-admin binding | Cluster Hardening | 7 | |
| SH1 · seccomp profile | System Hardening | 5 | |
| SH2 · AppArmor profile | System Hardening | 5 | |
| MV1 · Pod Security Admission restricted | Minimize Microservice Vulnerabilities | 7 | |
| MV2 · etcd encryption at rest | Minimize Microservice Vulnerabilities | 7 | |
| MV3 · gVisor sandboxed workload | Minimize Microservice Vulnerabilities | 6 | |
| SC1 · registry-allowlist admission policy | Supply Chain Security | 7 | |
| SC2 · Trivy scan, act on the result | Supply Chain Security | 7 | |
| SC3 · cosign signature verification | Supply Chain Security | 6 | |
| MR1 · audit policy, Secrets vs pods/exec | Monitoring, Logging & Runtime Security | 7 | |
| MR2 · scoped Falco rule, proven live | Monitoring, Logging & Runtime Security | 7 | |
| MR3 · immutability + ephemeral debug container | Monitoring, Logging & Runtime Security | 6 | |
| Total | All six domains | 100 |
Below your target here, re-run the matching material rather than re-reading this page's answer key on its own: CS1–CS2 point back to the CKS blueprint's Cluster Setup section, CH1–CH2 to the RBAC-hardening drill, SH1–SH2 to DevSecOps's Kubernetes Security Deep Dive, MV1–MV3 to Capstone Part 5, SC1–SC3 to Container & Supply-Chain Security, and MR1–MR3 to Security: Defense in Depth.
This is an independent, unofficial study resource — not affiliated with the CNCF or the Linux Foundation. The roughly two-hour duration, the 15–20 task range this paper's fifteen tasks sit inside, the commonly cited 67% pass mark, price, validity, and permitted-documentation allowlist referenced on this page all change over time — and the CKS additionally requires an active, non-expired CKA to even register, which is checked at booking, not suggested. Confirm current details on the official Linux Foundation CKS page and the CNCF certification page before you pay for anything. See Platform Engineering's CKS page for the same exam from a platform-engineer's angle, and the Golden Astronaut course for the other nine CNCF certifications and LFCS if the CKS is one stop on your way to Kubestronaut or Golden Kubestronaut.
Ellie: MR1 caught me out for a minute — I wrote the catch-all Metadata rule first out of habit, and then couldn't figure out why my pods/exec line never showed RequestResponse.
Timmy: First match wins. Always. Put the narrow rules first, the wide one last — the same instinct as writing the specific Ingress path before the catch-all one on the CKAD side of this ladder.
Gizmo: Or just set every rule to RequestResponse and skip the whole ordering headache. You'll definitely catch everything then. 🤑
Ellie: You'd catch everything, including every Secret's plaintext value, written straight into a log file. That's not a shortcut, that's the exact leak this domain exists to prevent.
Benny: MV2 took me the longest of the whole paper — I kept checking kubectl get secret -o yaml and couldn't understand why it "still looked the same" after I turned encryption on.
Timmy: Because the API server decrypts it for you on the way out, every time — that's the whole reason MV2's own done-when check reads straight out of etcd instead. If your verification step goes back through the API server, you've only proven the API server still works, not that anything changed underneath it.
Pip: Same lesson as CS2's metadata block, honestly — it's never enough that a fix looks right in the manifest. Something has to actually try the forbidden path and fail.
1. In CS1, why does fixing only one of --anonymous-auth or --authorization-mode still leave a real hole? 2. In CH1, which value wins when a Pod sets automountServiceAccountToken: true but its ServiceAccount sets it false? 3. Why does MV2's done-when check read directly from etcd instead of using kubectl get secret? 4. In MR1, why must the secrets and pods/exec audit rules appear before the catch-all Metadata rule, and what specifically breaks if they don't? 5. What's the structural difference between what SH1's seccomp profile restricts and what SH2's AppArmor profile restricts? 6. Why does SC2 use --ignore-unfixed rather than failing the gate on every CRITICAL finding regardless of whether a fix exists?
Check your answers
- Disabling anonymous auth alone still leaves any request that authorization would rubber-stamp under
AlwaysAllow; leaving authorization asAlwaysAllowalone still lets an anonymous request through if authentication doesn't reject it outright under every code path. The two checks close the same door from different sides — one alone leaves it ajar. - The Pod-level setting always wins over the ServiceAccount-level default — that's exactly the mechanism CH1 depends on: set the SA default
falsecluster-wide, then override ittrueon the one Pod template that actually needs a token. - Because the API server transparently decrypts a Secret on every read through its own API, regardless of what's actually stored underneath —
kubectl get secretwould show the same decoded value whether encryption at rest is on or off, so it can never prove the etcd-level control is working. Reading the raw etcd key is the only way to see what's actually stored. - Audit policy rules are evaluated first-match-wins, in list order. If the catch-all
Metadatarule came first, it would claim every single request — including Secrets access andpods/execcalls — before the narrower, more specific rules ever got a chance to apply their own levels. - Seccomp restricts which syscalls a process may invoke at all — an operation-level filter enforced by the kernel before the syscall runs. AppArmor restricts which paths an otherwise-permitted operation, like a write, is allowed to target — a path-level filter. They answer different questions and stack rather than overlap.
- Because a CVE with no fix available yet can't be resolved by choosing a different image tag — failing a gate on an unfixable finding only teaches the team to override or ignore the gate, which defeats its purpose. Scoping to fixable findings keeps every failure actionable.
That's Set 1. Because this paper tracks the official curriculum's own domain weights, a clean pass here is a fair signal you're close to ready — but sit it more than once, on freshly rebuilt clusters, before you trust a single score, and remember the CKS itself is gated behind an active CKA the way this paper isn't. For a second full paper to check your score holds up on new tasks, see Set 2; for shorter, single-domain reps instead of a full timed sitting, see CKS Practice Tasks; and for the full week-by-week plan this paper sits at the end of, see the CKS study plan.