# 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` (adjust port to the ingress-nginx NodePort/hostPort in your setup). --- ## 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 dev` → **no**; `... 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): ```bash # on the VM cat > /tmp/deny-write <<'EOF' #include profile k8s-deny-write flags=(attach_disconnected) { #include 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: ```bash 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: ```bash 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: ```bash # 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): ```bash 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.