docs: add Kubernetes CKS study notes

This commit is contained in:
2026-08-04 23:18:45 +02:00
parent a6ee7a2b07
commit 91a1849009
57 changed files with 8313 additions and 0 deletions

View File

@@ -0,0 +1,344 @@
# 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 <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:
```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.