Files
Jan Novak 6f28c97319 cks: swap kind CNI to Cilium, fix broken ingress-nginx setup
kindnet's NetworkPolicy enforcement isn't guaranteed, undermining the
Task 1 lab; Cilium (with kube-proxy replacement) enforces it
deterministically. Also fixes ingress-nginx, which was silently broken:
missing ingress-ready node label left the controller Pending forever,
and hostPorts 80/443 were never mapped in kind-config.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 23:23:04 +02:00

15 KiB

CKS Mock Exam — k8s v1.35

Format mirrors the real thing: 17 tasks, 2 hours, ~66% to pass. Each task shows a weight and a domain. In the real exam every task tells you which cluster/context to kubectl config use-context into first — here it's all one cluster (kind-cks), but the habit of reading the context line first is baked in anyway. Tasks touching control-plane files run via docker exec cks-control-plane ...; host/kernel tasks run on the node container or your Ubuntu VM as noted.

Ground rules (same as real exam):

  • Bookmark allowed docs only: kubernetes.io/docs, kubernetes.io/blog, falco.org, and the tool projects. Practice with those tabs only.
  • No stopping the clock. Do the fast, formulaic tasks first (NetworkPolicy, RBAC, PSA labels), leave apiserver-restart tasks for a block where you can afford the ~1 min downtime.
  • Every task: kubectl config use-context kind-cks before you start (muscle memory).

Setup: ./bootstrap.sh then ./seed.sh. Per-task extra setup is in each Setup block below.

Weight tally: Cluster Setup 15 · Cluster Hardening 15 · System Hardening 10 · Microservice Vuln 20 · Supply Chain 20 · Monitoring/Logging/Runtime 20.


Task 1 — Default-deny + selective NetworkPolicy · Cluster Setup · 4%

Context: kind-cks

Setup: seeded by seed.sh (namespace prod: db, frontend, attacker).

Task: In namespace prod, the db workload must only be reachable from the frontend workload.

  1. Create a NetworkPolicy default-deny in prod that denies all ingress to every pod in the namespace.
  2. Create a NetworkPolicy allow-frontend in prod that allows ingress to pods labelled app=db only from pods labelled app=frontend, on TCP 5432.

Do not modify the deployments. attacker must NOT be able to reach db; frontend must.

Verify: exec into frontend and attacker, curl db:5432 — frontend succeeds, attacker times out.


Task 2 — TLS-terminated Ingress · Cluster Setup · 4%

Context: kind-cks

Setup: seeded (web ns: hello Deployment + Service). ingress-nginx installed by bootstrap.

Task: Expose the hello Service in namespace web over HTTPS.

  1. Create a TLS Secret hello-tls in web from a self-signed cert for host hello.cks.local (you generate the cert/key on the VM).
  2. Create an Ingress hello in web routing host hello.cks.local path / to Service hello:80, using hello-tls for TLS.

Verify: curl -k --resolve hello.cks.local:443:127.0.0.1 https://hello.cks.local/ returns hello-tls.


Task 3 — kube-bench: remediate CIS findings · Cluster Setup · 5%

Context: kind-cks (control-plane node)

Setup: none beyond the cluster.

Task: Run kube-bench against the control-plane node and remediate the following FAIL items so a re-run passes:

  • kubelet: --anonymous-auth must be false.
  • kubelet: --authorization-mode must not be AlwaysAllow (use Webhook).
  • kubelet: read-only port must be disabled (--read-only-port=0).
  • kube-apiserver: --profiling must be false.

Apply the kubelet changes via the kubelet config on the node and restart kubelet; apply the apiserver change via the static manifest. Re-run kube-bench to confirm those four checks pass.

The kubelet config on a kind node is /var/lib/kubelet/config.yaml; the apiserver manifest is /etc/kubernetes/manifests/kube-apiserver.yaml.


Task 4 — RBAC least privilege · Cluster Hardening · 5%

Context: kind-cks

Setup: seeded (dev ns: SA ci-runner, Role ci-runner-role with */*/*, RoleBinding ci-runner-rb).

Task: The ci-runner ServiceAccount in dev is bound to a cluster-admin-equivalent Role. Restrict it to least privilege:

  • ci-runner must be able to get, list, watch only pods and configmaps in dev.
  • It must have no other permissions anywhere.

Do not delete the SA or the RoleBinding — reshape the Role (or replace it) so the binding still points at the right role name. Verify with kubectl auth can-i.

Verify: kubectl auth can-i delete pods --as=system:serviceaccount:dev:ci-runner -n devno; ... get pods ...yes.


Task 5 — API server hardening · Cluster Hardening · 5%

Context: kind-cks (control-plane)

Setup: none.

Task: Edit the kube-apiserver static manifest so that:

  1. Anonymous auth is disabled (--anonymous-auth=false).
  2. The NodeRestriction admission plugin is enabled.
  3. Profiling is disabled (--profiling=false) — if you already did this in Task 3, confirm it's present.

Wait for the apiserver static pod to come back healthy before moving on.

Verify: kubectl -n kube-system get pod -l component=kube-apiserver Running; curl -k https://127.0.0.1:6443/healthz from inside the node returns anonymous 401/403 rather than ok.


Task 6 — Disable ServiceAccount token automount · Cluster Hardening · 5%

Context: kind-cks

Setup: seeded (apps ns: SA web-sa, Deployment web).

Task: Pods in namespace apps should not receive an automounted ServiceAccount token unless they explicitly need one.

  1. Set automountServiceAccountToken: false on the web-sa ServiceAccount.
  2. Ensure the running web pods no longer mount the token (roll them if needed).

Do not change the ServiceAccount name or the Deployment's serviceAccountName.

Verify: kubectl -n apps exec deploy/web -- ls /var/run/secrets/kubernetes.io/serviceaccount → no such file/dir.


Task 7 — AppArmor profile enforcement · System Hardening · 5%

Context: kind-cks (worker node + cluster)

Setup: run this to stage the profile file on a worker (host must have AppArmor — Ubuntu VM does):

# on the VM
cat > /tmp/deny-write <<'EOF'
#include <tunables/global>
profile k8s-deny-write flags=(attach_disconnected) {
  #include <abstractions/base>
  file,
  deny /** w,
}
EOF
docker cp /tmp/deny-write cks-worker:/root/deny-write

Task:

  1. Load the AppArmor profile k8s-deny-write in enforce mode on node cks-worker.
  2. Create a Pod locked in namespace sysh running image: busybox:1.36, command sleep→ use ["sh","-c","sleep 1h"], that runs under the k8s-deny-write profile using the AppArmor securityContext API (not the deprecated annotation). Pin it to cks-worker.

Verify: kubectl -n sysh exec locked -- sh -c 'echo x > /tmp/x'Permission denied.


Task 8 — seccomp custom profile · System Hardening · 5%

Context: kind-cks (worker node + cluster)

Setup: stage a custom seccomp profile on the worker:

cat > /tmp/audit.json <<'EOF'
{ "defaultAction": "SCMP_ACT_LOG" }
EOF
docker exec cks-worker mkdir -p /var/lib/kubelet/seccomp/profiles
docker cp /tmp/audit.json cks-worker:/var/lib/kubelet/seccomp/profiles/audit.json

Task: Create a Pod traced in namespace sysh (image: busybox:1.36, ["sh","-c","sleep 1h"], pinned to cks-worker) that uses the Localhost seccomp profile profiles/audit.json. Then create a second Pod defaulted in sysh that uses the RuntimeDefault seccomp profile.

Verify: kubectl -n sysh get pod traced -o jsonpath='{.spec.securityContext.seccompProfile}' shows the Localhost profile.


Task 9 — Enforce restricted Pod Security Standard · Microservice Vuln · 5%

Context: kind-cks

Setup: seeded (restricted-ns: Deployment payments that is privileged → violates restricted).

Task:

  1. Label namespace restricted-ns to enforce the restricted Pod Security Standard (latest version), and to warn/audit at restricted too.
  2. The existing payments Deployment currently violates it. Modify the pod template so it complies with restricted (drop privilege, set the required securityContext fields) and rolls out successfully.

Verify: kubectl -n restricted-ns get deploy payments has ready replicas; creating a privileged pod in the ns is rejected.


Task 10 — Encrypt Secrets at rest · Microservice Vuln · 6%

Context: kind-cks (control-plane)

Setup: create a secret that currently sits in etcd in plaintext:

kubectl create ns vault-ns --dry-run=client -o yaml | kubectl apply -f -
kubectl -n vault-ns create secret generic pre-existing --from-literal=api=SUPERSECRET

Task:

  1. Configure an EncryptionConfiguration (provider aescbc with a 32-byte key, identity as fallback) at /etc/kubernetes/enc/enc.yaml on the control-plane, and point the apiserver at it via --encryption-provider-config (mount it into the static pod).
  2. Ensure all existing Secrets cluster-wide are rewritten so they're stored encrypted.

Verify: read the raw etcd value for secret/vault-ns/pre-existing with etcdctl inside the control-plane — it must start with k8s:enc:aescbc: and not contain SUPERSECRET.


Task 11 — RuntimeClass for sandboxed workload · Microservice Vuln · 4%

Context: kind-cks

Setup: none.

Task:

  1. Create a RuntimeClass gvisor with handler runsc.
  2. Create a Pod sandboxed in namespace runtime (image: nginx:1.27) that runs under the gvisor RuntimeClass.

Note: kind's containerd has no runsc handler, so the pod will stay Pending/ContainerCreating — that's expected. The graded artifact is the correct RuntimeClass + pod spec. (On a VM with gVisor + a containerd runsc runtime configured, it would actually run.)

Verify: kubectl get runtimeclass gvisor exists with handler runsc; pod references it.


Task 12 — ValidatingAdmissionPolicy (in-tree, CEL) · Microservice Vuln · 5%

Context: kind-cks

Setup: none.

Task: Using the built-in ValidatingAdmissionPolicy API (no external webhook, no Kyverno/OPA), block the creation of any Pod that:

  • sets privileged: true on any container, or
  • uses a hostPath volume.

Enforce it cluster-wide with failurePolicy: Fail. Bind it so it's active.

Verify: applying a pod with privileged: true or a hostPath volume is denied by the policy; a clean pod is admitted.


Task 13 — Trivy image scan & evict vulnerable pods · Supply Chain · 6%

Context: kind-cks

Setup: seeded (images ns: legacy-app [nginx:1.19.0], old-debian [debian:10], clean-app [nginx:1.27]).

Task: Using trivy on the VM, scan the images used by the pods in namespace images. Delete every pod whose image has at least one HIGH or CRITICAL severity, OS-level vulnerability. Leave the rest running.

Verify: legacy-app and old-debian gone; clean-app (or whichever scans clean of HIGH/CRITICAL) remains.


Task 14 — Kyverno policy: no :latest, trusted registries only · Supply Chain · 6%

Context: kind-cks

Setup: install Kyverno (latest) — do this at the start of the task:

# latest install manifest
kubectl create -f https://github.com/kyverno/kyverno/releases/latest/download/install.yaml
kubectl -n kyverno rollout status deploy/kyverno-admission-controller --timeout=180s
kubectl create ns team-a --dry-run=client -o yaml | kubectl apply -f -

Task: Author Kyverno ClusterPolicies (enforce mode) that:

  1. Reject any Pod whose container image uses the :latest tag or has no tag.
  2. Reject any Pod whose image does not come from registry registry.cks.local (allow that registry only).

Apply them cluster-wide.

Verify: kubectl -n team-a run bad --image=nginx:latest → denied; kubectl -n team-a run bad2 --image=docker.io/nginx:1.27 → denied (wrong registry); ... --image=registry.cks.local/nginx:1.27 → admitted (may fail to pull, but admission passes).


Task 15 — ImagePolicyWebhook admission control · Supply Chain · 4%

Context: kind-cks (control-plane)

Setup: stage the admission config + webhook kubeconfig (you'll write these as part of the task; skeleton paths below):

  • /etc/kubernetes/admission/admission-config.yaml
  • /etc/kubernetes/admission/imagepolicy-kubeconfig.yaml

Task: Enable the ImagePolicyWebhook admission plugin on the kube-apiserver and wire it to an AdmissionConfiguration file that:

  • references the webhook via a kubeconfig,
  • sets defaultAllow: false in the ImagePolicy plugin config,

Mount both files into the static pod and enable the plugin. (No live backend is required — grading is on the admission plugin being enabled and the config file being correctly referenced. With defaultAllow: false and no reachable backend, new pod creation should be rejected — demonstrate that, then set defaultAllow: true to restore usability if you want the cluster functional for later tasks.)

Verify: with defaultAllow: false, kubectl run t --image=nginx:1.27 is rejected by the ImagePolicyWebhook.


Task 16 — Falco: custom runtime rule · Monitoring/Logging/Runtime · 7%

Context: Ubuntu VM (host-installed Falco) — this mirrors the classic CKS Falco task.

Setup: install Falco on the VM (latest, v0.44.x, modern eBPF):

curl -fsSL https://falco.org/repo/falcosecurity-packages.asc | sudo gpg --dearmor -o /usr/share/keyrings/falco-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/falco-archive-keyring.gpg] https://download.falco.org/packages/deb stable main" | sudo tee /etc/apt/sources.list.d/falcosecurity.list
sudo apt update && sudo apt install -y falco

(If you'd rather do it in-cluster: helm install falco falcosecurity/falco -n falco --create-namespace --set driver.kind=modern_ebpf — but the on-VM path is closer to the exam.)

Task:

  1. Add a custom Falco rule (in /etc/falco/falco_rules.local.yaml) named Shell in container that fires at priority WARNING whenever a shell (sh, bash) is spawned inside any container, with output including the container id, container name, and the spawned process name.
  2. Restart Falco, trigger the rule (spawn a shell in a container), and capture the alert line to /opt/course/falco-hits.txt.

Verify: the rule appears in Falco's loaded rules; triggering a shell produces a Shell in container alert containing the container name and proc name.


Task 17 — API server audit logging · Monitoring/Logging/Runtime · 6%

Context: kind-cks (control-plane)

Setup: none.

Task: Configure kube-apiserver audit logging:

  1. Write an audit policy at /etc/kubernetes/audit/policy.yaml that:
    • logs Metadata level for secrets, configmaps in all namespaces,
    • logs RequestResponse level for pods changes (create/update/delete),
    • logs everything else at None (don't log read-only noise).
  2. Enable it on the apiserver: --audit-policy-file, --audit-log-path=/var/log/kubernetes/audit/audit.log, --audit-log-maxage=7, --audit-log-maxbackup=2, --audit-log-maxsize=50. Mount the policy (read-only) and the log dir (read-write) into the static pod.

Verify: after the apiserver restarts, kubectl -n vault-ns get secret pre-existing then check /var/log/kubernetes/audit/audit.log inside the control-plane contains a metadata-level entry for that secret and no RequestResponse bodies for it.


Scoring yourself

  • 12+/17 fully correct ≈ comfortable pass.
  • Time-box: if a control-plane restart task (5, 10, 15, 17) breaks the apiserver, you lose access to everything — practice reverting fast (docker exec ... edit back, or cp a backup of the manifest you took first). Always cp the manifest before editing.

Answers in answers/solutions.md. Don't peek until you've timed a full run.