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,37 @@
# CKS Mock Exam — k8s v1.35
Timed practice that mirrors the current CKS (17 tasks, 2h, six domains). Runs on kind (k8s v1.35.5) with the host-side / kernel bits on your Ubuntu VM.
## Prereqs (Ubuntu VM)
- docker, `kind >= v0.32.0`, `kubectl` v1.35, `helm`
- host tools installed latest: `kube-bench`, `trivy`, `kubesec`, `falco` (v0.44.x), optional `cosign`
- host must have AppArmor enabled (default on Ubuntu) for Task 7
## Quickstart
```bash
chmod +x bootstrap.sh seed.sh
./bootstrap.sh # creates 'cks' cluster (1 cp + 2 workers) + ingress-nginx
./seed.sh # plants the target/vulnerable objects
# set a 2h timer, open exam.md, go.
```
## Files
- `kind-config.yaml` — cluster topology, v1.35.5 pinned digest, exam-files mount
- `bootstrap.sh` — cluster + ingress controller + tooling checklist
- `seed.sh` — objects for the kubectl-only tasks
- `exam.md` — the 17 tasks (per-task setup + statement, no answers)
- `answers/solutions.md` — worked solutions (don't peek until you've timed a full run)
## Domain coverage (weights)
Cluster Setup 15 · Cluster Hardening 15 · System Hardening 10 · Microservice Vuln 20 · Supply Chain 20 · Monitoring/Logging/Runtime 20.
## Reset
```bash
kind delete cluster --name cks && ./bootstrap.sh && ./seed.sh
```
## Notes / kind caveats
- **Version:** exam is on v1.35 per the Linux Foundation page. k8s 1.36 shipped ~May 2026; the exam env aligns "within 48 weeks" of a release, so it *may* have rolled to 1.36 by your date — verify on the LF exam page. Nothing in this set changes between 1.35/1.36. To bump: swap the digest in `kind-config.yaml` for a `kindest/node:v1.36.x` one.
- **AppArmor** needs a real AppArmor host; works on Ubuntu, not on Docker Desktop/macOS.
- **gVisor (Task 11)** pod stays Pending in kind (no `runsc` in kind's containerd) — the config is the graded artifact.
- **ImagePolicyWebhook / audit / encryption** edit the live apiserver: always `cp` the manifest first so you can revert fast.

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# CKS mock — cluster bootstrap. Run on your Ubuntu VM (needs docker, kind >= v0.32.0, kubectl v1.35, helm).
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$HERE"
mkdir -p exam-files
echo "[*] Creating kind cluster 'cks' (1 cp + 2 workers, k8s v1.35.5)..."
kind create cluster --config kind-config.yaml
kubectl config use-context kind-cks
echo "[*] Installing an ingress controller (needed for the TLS task)..."
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
kubectl -n ingress-nginx wait --for=condition=Ready pod \
-l app.kubernetes.io/component=controller --timeout=180s || true
cat <<'EOF'
[*] Cluster up. Sanity check:
kubectl get nodes -o wide
[*] Host-side tooling you want on the VM (install latest):
- kube-bench : https://github.com/aquasecurity/kube-bench/releases (or run as a Job in-cluster)
- trivy : https://github.com/aquasecurity/trivy (apt: aquasecurity repo)
- kubesec : https://github.com/controlplaneio/kubesec/releases
- falco : apt install falco (v0.44.1) — used in the runtime task on the VM
- helm : for Kyverno / Falco chart installs
- cosign : optional, image signing task
[*] Now run ./seed.sh to plant the vulnerable/target objects for the kubectl-only tasks.
EOF

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.

View File

@@ -0,0 +1,22 @@
# CKS mock cluster — 1 control-plane + 2 workers, k8s v1.35
# Pinned digest from kind v0.32.0 (default 1.36) — we force 1.35.5 to match the exam.
# If `kind load`/image pull complains, use kind >= v0.32.0.
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: cks
nodes:
- role: control-plane
image: kindest/node:v1.35.5@sha256:ce977ae6d65918d0b58a5f8b5e940429c2ce42fa3a5619ec2bbc60b949c0ac95
extraMounts:
# host dir for audit policy / encryption config / admission config you'll author on the VM
- hostPath: ./exam-files
containerPath: /etc/kubernetes/exam
extraPortMappings:
# so you can curl the Ingress task from the VM
- containerPort: 30443
hostPort: 30443
protocol: TCP
- role: worker
image: kindest/node:v1.35.5@sha256:ce977ae6d65918d0b58a5f8b5e940429c2ce42fa3a5619ec2bbc60b949c0ac95
- role: worker
image: kindest/node:v1.35.5@sha256:ce977ae6d65918d0b58a5f8b5e940429c2ce42fa3a5619ec2bbc60b949c0ac95

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env bash
# CKS mock — seed the cluster with the objects each task expects. Idempotent-ish.
set -euo pipefail
kubectl config use-context kind-cks >/dev/null
echo "[*] Namespaces..."
for ns in prod web dev apps restricted-ns images sysh runtime; do
kubectl create ns "$ns" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
done
# ---------- Task 1: NetworkPolicy ----------
kubectl -n prod apply -f - >/dev/null <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: db, namespace: prod, labels: {app: db}}
spec:
replicas: 1
selector: {matchLabels: {app: db}}
template:
metadata: {labels: {app: db}}
spec:
containers:
- name: db
image: hashicorp/http-echo:1.0
args: ["-text=db-ok", "-listen=:5432"]
ports: [{containerPort: 5432}]
---
apiVersion: v1
kind: Service
metadata: {name: db, namespace: prod}
spec:
selector: {app: db}
ports: [{port: 5432, targetPort: 5432}]
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: frontend, namespace: prod, labels: {app: frontend}}
spec:
replicas: 1
selector: {matchLabels: {app: frontend}}
template:
metadata: {labels: {app: frontend}}
spec: {containers: [{name: c, image: curlimages/curl:8.11.1, command: ["sleep","infinity"]}]}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: attacker, namespace: prod, labels: {app: attacker}}
spec:
replicas: 1
selector: {matchLabels: {app: attacker}}
template:
metadata: {labels: {app: attacker}}
spec: {containers: [{name: c, image: curlimages/curl:8.11.1, command: ["sleep","infinity"]}]}
EOF
# ---------- Task 2: Ingress TLS ----------
kubectl -n web apply -f - >/dev/null <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: hello, namespace: web}
spec:
replicas: 1
selector: {matchLabels: {app: hello}}
template:
metadata: {labels: {app: hello}}
spec:
containers:
- name: hello
image: hashicorp/http-echo:1.0
args: ["-text=hello-tls", "-listen=:5678"]
ports: [{containerPort: 5678}]
---
apiVersion: v1
kind: Service
metadata: {name: hello, namespace: web}
spec:
selector: {app: hello}
ports: [{port: 80, targetPort: 5678}]
EOF
# ---------- Task 4: RBAC least-privilege (over-permissive to fix) ----------
kubectl -n dev apply -f - >/dev/null <<'EOF'
apiVersion: v1
kind: ServiceAccount
metadata: {name: ci-runner, namespace: dev}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: {name: ci-runner-role, namespace: dev}
rules:
- apiGroups: ["*"]
resources: ["*"]
verbs: ["*"] # WAY too broad — task: restrict to get/list/watch on pods & configmaps
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: {name: ci-runner-rb, namespace: dev}
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: Role, name: ci-runner-role}
subjects: [{kind: ServiceAccount, name: ci-runner, namespace: dev}]
EOF
# ---------- Task 6: automountServiceAccountToken ----------
kubectl -n apps apply -f - >/dev/null <<'EOF'
apiVersion: v1
kind: ServiceAccount
metadata: {name: web-sa, namespace: apps}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: web, namespace: apps}
spec:
replicas: 2
selector: {matchLabels: {app: web}}
template:
metadata: {labels: {app: web}}
spec:
serviceAccountName: web-sa
containers: [{name: c, image: nginx:1.27, ports: [{containerPort: 80}]}]
EOF
# ---------- Task 9: Pod Security Admission (violating workload) ----------
kubectl -n restricted-ns apply -f - >/dev/null <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: payments, namespace: restricted-ns}
spec:
replicas: 1
selector: {matchLabels: {app: payments}}
template:
metadata: {labels: {app: payments}}
spec:
containers:
- name: c
image: nginx:1.27
securityContext:
privileged: true # violates restricted
allowPrivilegeEscalation: true
EOF
# ---------- Task 13: Trivy image scan (mixed vuln levels) ----------
kubectl -n images apply -f - >/dev/null <<'EOF'
apiVersion: v1
kind: Pod
metadata: {name: legacy-app, namespace: images, labels: {scan: "true"}}
spec:
containers: [{name: c, image: nginx:1.19.0}] # old, HIGH/CRITICAL CVEs
---
apiVersion: v1
kind: Pod
metadata: {name: old-debian, namespace: images, labels: {scan: "true"}}
spec:
containers: [{name: c, image: debian:10}] # EOL, plenty of CVEs
---
apiVersion: v1
kind: Pod
metadata: {name: clean-app, namespace: images, labels: {scan: "true"}}
spec:
containers: [{name: c, image: nginx:1.27}] # relatively clean
EOF
echo "[*] Seed complete. Namespaces: prod web dev apps restricted-ns images sysh runtime"
echo "[*] Note: Kyverno / Falco / gVisor get installed inside their own task setup blocks."

View File

@@ -0,0 +1,545 @@
# CKS Mock — Solutions (k8s v1.35)
Reference answers. There's usually more than one valid path; these are the exam-fast ones. Control-plane file edits assume `docker exec -it cks-control-plane bash` (or `cks-worker` for node tasks).
> **Before ANY apiserver edit:** `cp /etc/kubernetes/manifests/kube-apiserver.yaml ~/kube-apiserver.yaml.bak` inside the node. If the pod won't come back, `cp` it back.
---
## Task 1 — NetworkPolicy
```yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: default-deny, namespace: prod}
spec:
podSelector: {}
policyTypes: [Ingress]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: allow-frontend, namespace: prod}
spec:
podSelector: {matchLabels: {app: db}}
policyTypes: [Ingress]
ingress:
- from:
- podSelector: {matchLabels: {app: frontend}}
ports:
- {protocol: TCP, port: 5432}
```
Verify:
```bash
kubectl -n prod exec deploy/frontend -- curl -s --max-time 3 db:5432 # -> db-ok
kubectl -n prod exec deploy/attacker -- curl -s --max-time 3 db:5432 # -> timeout
```
Gotcha: kind's default CNI (kindnetd) enforces NetworkPolicy in recent versions; if your kindnet build doesn't, swap to Calico. On the exam CNI always enforces.
---
## Task 2 — Ingress TLS
```bash
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout tls.key -out tls.crt -subj "/CN=hello.cks.local/O=cks"
kubectl -n web create secret tls hello-tls --cert=tls.crt --key=tls.key
```
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hello
namespace: web
spec:
ingressClassName: nginx
tls:
- hosts: [hello.cks.local]
secretName: hello-tls
rules:
- host: hello.cks.local
http:
paths:
- path: /
pathType: Prefix
backend: {service: {name: hello, port: {number: 80}}}
```
Verify (port depends on how ingress-nginx is exposed in kind — NodePort or the hostPort mapping):
```bash
kubectl -n ingress-nginx get svc ingress-nginx-controller
curl -k --resolve hello.cks.local:443:127.0.0.1 https://hello.cks.local/
```
---
## Task 3 — kube-bench remediation
Run it (Job is easiest inside kind):
```bash
kubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job.yaml
kubectl logs -f job/kube-bench | less
```
Or download the binary and `kube-bench run --targets master,node` on the node.
Kubelet — edit on the node `/var/lib/kubelet/config.yaml`:
```yaml
authentication:
anonymous:
enabled: false
webhook:
enabled: true
authorization:
mode: Webhook
readOnlyPort: 0
```
```bash
systemctl restart kubelet
```
API server — `/etc/kubernetes/manifests/kube-apiserver.yaml`, add to `command`:
```
- --profiling=false
```
Re-run kube-bench; those checks flip to PASS.
---
## Task 4 — RBAC least privilege
Replace the Role (binding already points at `ci-runner-role`):
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: {name: ci-runner-role, namespace: dev}
rules:
- apiGroups: [""]
resources: ["pods", "configmaps"]
verbs: ["get", "list", "watch"]
```
```bash
kubectl -n dev replace -f role.yaml # or edit
kubectl auth can-i get pods --as=system:serviceaccount:dev:ci-runner -n dev # yes
kubectl auth can-i delete pods --as=system:serviceaccount:dev:ci-runner -n dev # no
kubectl auth can-i '*' secrets --as=system:serviceaccount:dev:ci-runner -n dev # no
```
---
## Task 5 — API server hardening
`/etc/kubernetes/manifests/kube-apiserver.yaml`, in `command`:
```
- --anonymous-auth=false
- --profiling=false
- --enable-admission-plugins=NodeRestriction # merge with existing list, comma-separated
```
If `--enable-admission-plugins` already exists, append `,NodeRestriction` to it — don't add a second flag.
Wait for restart:
```bash
kubectl -n kube-system get pod -l component=kube-apiserver -w
```
Note: with `--anonymous-auth=false`, `/healthz` and `/livez` still work for the kubelet because it authenticates; unauthenticated curl gets 401.
---
## Task 6 — automountServiceAccountToken
```bash
kubectl -n apps patch sa web-sa -p '{"automountServiceAccountToken": false}'
kubectl -n apps rollout restart deploy/web
kubectl -n apps exec deploy/web -- ls /var/run/secrets/kubernetes.io/serviceaccount 2>&1 # No such file
```
(Alternatively set `automountServiceAccountToken: false` in the pod template — SA-level is cleaner here and is what the task asks.)
---
## Task 7 — AppArmor (GA securityContext API)
Load profile on the worker:
```bash
docker exec cks-worker apparmor_parser -q /root/deny-write
docker exec cks-worker aa-status | grep k8s-deny-write # enforce
```
Pod — **use the appArmorProfile field, not the beta annotation**:
```yaml
apiVersion: v1
kind: Pod
metadata: {name: locked, namespace: sysh}
spec:
nodeName: cks-worker
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: k8s-deny-write
containers:
- name: c
image: busybox:1.36
command: ["sh","-c","sleep 1h"]
```
```bash
kubectl -n sysh exec locked -- sh -c 'echo x > /tmp/x' # Permission denied
```
Gotcha: the profile only exists on `cks-worker`, so you must pin the pod there or the load must be on every schedulable node.
---
## Task 8 — seccomp
```yaml
apiVersion: v1
kind: Pod
metadata: {name: traced, namespace: sysh}
spec:
nodeName: cks-worker
securityContext:
seccompProfile:
type: Localhost
localhostProfile: profiles/audit.json
containers:
- {name: c, image: busybox:1.36, command: ["sh","-c","sleep 1h"]}
---
apiVersion: v1
kind: Pod
metadata: {name: defaulted, namespace: sysh}
spec:
nodeName: cks-worker
securityContext:
seccompProfile: {type: RuntimeDefault}
containers:
- {name: c, image: busybox:1.36, command: ["sh","-c","sleep 1h"]}
```
`localhostProfile` is relative to the kubelet seccomp root (`/var/lib/kubelet/seccomp`), so `profiles/audit.json` resolves to `/var/lib/kubelet/seccomp/profiles/audit.json`.
---
## Task 9 — Pod Security Admission (restricted)
Label the namespace:
```bash
kubectl label ns restricted-ns \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=latest \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/audit=restricted --overwrite
```
Fix the Deployment pod template to satisfy `restricted`:
```yaml
spec:
securityContext:
runAsNonRoot: true
seccompProfile: {type: RuntimeDefault}
containers:
- name: c
image: nginx:1.27
securityContext:
allowPrivilegeEscalation: false
privileged: false
runAsNonRoot: true
capabilities: {drop: ["ALL"]}
```
nginx:1.27 wants to bind :80 — under `runAsNonRoot` it'll fail unless you use an unprivileged image/port. For the exam-grade "complies + rolls out," use `nginxinc/nginx-unprivileged:1.27` (listens on 8080) or set `runAsUser: 101`. The graded bit is restricted-compliance; the unprivileged image makes it actually run.
---
## Task 10 — Encryption at rest
Key + config on the control-plane:
```bash
mkdir -p /etc/kubernetes/enc
head -c 32 /dev/urandom | base64 # copy the value
cat > /etc/kubernetes/enc/enc.yaml <<EOF
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources: ["secrets"]
providers:
- aescbc:
keys:
- name: key1
secret: <PASTE_BASE64_32B_KEY>
- identity: {}
EOF
```
apiserver manifest — add flag + volume + mount:
```yaml
# command:
- --encryption-provider-config=/etc/kubernetes/enc/enc.yaml
# volumes:
- name: enc
hostPath: {path: /etc/kubernetes/enc, type: DirectoryOrCreate}
# volumeMounts:
- name: enc
mountPath: /etc/kubernetes/enc
readOnly: true
```
After apiserver is back, rewrite all secrets:
```bash
kubectl get secrets -A -o json | kubectl replace -f -
```
Verify with etcdctl inside the control-plane:
```bash
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/vault-ns/pre-existing | hexdump -C | head
# expect: k8s:enc:aescbc:v1:key1:... and NO plaintext SUPERSECRET
```
---
## Task 11 — RuntimeClass
```yaml
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata: {name: gvisor}
handler: runsc
---
apiVersion: v1
kind: Pod
metadata: {name: sandboxed, namespace: runtime}
spec:
runtimeClassName: gvisor
containers:
- {name: c, image: nginx:1.27}
```
Stays Pending in kind (no `runsc` handler in containerd) — expected. Config is the deliverable.
---
## Task 12 — ValidatingAdmissionPolicy
```yaml
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata: {name: pod-hardening}
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE","UPDATE"]
resources: ["pods"]
validations:
- expression: >-
!object.spec.containers.exists(c,
has(c.securityContext) && has(c.securityContext.privileged)
&& c.securityContext.privileged == true)
message: "privileged containers are not allowed"
- expression: >-
!has(object.spec.volumes) ||
!object.spec.volumes.exists(v, has(v.hostPath))
message: "hostPath volumes are not allowed"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata: {name: pod-hardening-binding}
spec:
policyName: pod-hardening
validationActions: ["Deny"]
matchResources: {} # all namespaces
```
Also check `initContainers`/`ephemeralContainers` if you want it airtight; for the task, `containers` is what's graded. Test:
```bash
kubectl run bad --image=nginx:1.27 --privileged # denied
kubectl run ok --image=nginx:1.27 # admitted
```
---
## Task 13 — Trivy scan & evict
```bash
for img in nginx:1.19.0 debian:10 nginx:1.27; do
echo "== $img =="
trivy image --severity HIGH,CRITICAL --quiet --scanners vuln "$img" | tail -5
done
```
`nginx:1.19.0` and `debian:10` will show HIGH/CRITICAL; delete those pods:
```bash
kubectl -n images delete pod legacy-app old-debian
kubectl -n images get pods # clean-app remains
```
Exam tip: map image→pod first (`kubectl -n images get pods -o custom-columns=POD:.metadata.name,IMG:.spec.containers[*].image`), scan each, delete by HIGH/CRITICAL count > 0.
---
## Task 14 — Kyverno policies
```yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata: {name: disallow-latest-tag}
spec:
validationFailureAction: Enforce
background: false
rules:
- name: require-explicit-tag
match: {any: [{resources: {kinds: [Pod]}}]}
validate:
message: "images must not use :latest or an empty tag"
pattern:
spec:
containers:
- image: "!*:latest"
- name: require-tag-present
match: {any: [{resources: {kinds: [Pod]}}]}
validate:
message: "image tag is required"
pattern:
spec:
containers:
- image: "*:*"
---
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata: {name: allowed-registries}
spec:
validationFailureAction: Enforce
background: false
rules:
- name: only-cks-registry
match: {any: [{resources: {kinds: [Pod]}}]}
validate:
message: "images must come from registry.cks.local"
pattern:
spec:
containers:
- image: "registry.cks.local/*"
```
Test:
```bash
kubectl -n team-a run bad --image=nginx:latest # denied (latest)
kubectl -n team-a run bad2 --image=docker.io/nginx:1.27 # denied (registry)
kubectl -n team-a run good --image=registry.cks.local/nginx:1.27 # admitted
```
Note: recent Kyverno also exposes the newer `validate.foreach`/CEL syntax; the pattern form above is the fastest to write under time.
---
## Task 15 — ImagePolicyWebhook
`/etc/kubernetes/admission/admission-config.yaml`:
```yaml
apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
- name: ImagePolicyWebhook
configuration:
imagePolicy:
kubeConfigFile: /etc/kubernetes/admission/imagepolicy-kubeconfig.yaml
allowTTL: 50
denyTTL: 50
retryBackoff: 500
defaultAllow: false
```
`/etc/kubernetes/admission/imagepolicy-kubeconfig.yaml`:
```yaml
apiVersion: v1
kind: Config
clusters:
- name: image-checker
cluster:
server: https://image-checker.local/check # no live backend needed for the drill
users:
- name: apiserver
contexts:
- name: default
context: {cluster: image-checker, user: apiserver}
current-context: default
```
apiserver manifest:
```yaml
# command:
- --enable-admission-plugins=NodeRestriction,ImagePolicyWebhook # merge with existing
- --admission-control-config-file=/etc/kubernetes/admission/admission-config.yaml
# volume + mount for /etc/kubernetes/admission (readOnly)
```
With `defaultAllow: false` and no reachable backend, `kubectl run t --image=nginx:1.27` → rejected by ImagePolicyWebhook. Flip to `defaultAllow: true` afterwards if you want the cluster usable.
---
## Task 16 — Falco custom rule
`/etc/falco/falco_rules.local.yaml`:
```yaml
- rule: Shell in container
desc: Detect a shell spawned inside a container
condition: >
spawned_process and container
and proc.name in (sh, bash)
output: >
Shell in container (container_id=%container.id container_name=%container.name
proc=%proc.name user=%user.name)
priority: WARNING
tags: [container, shell, mitre_execution]
```
```bash
systemctl restart falco # or: falco -r /etc/falco/falco_rules.yaml -r /etc/falco/falco_rules.local.yaml
# trigger:
kubectl run trigger --image=busybox:1.36 -- sh -c 'sleep 1h'
kubectl exec -it trigger -- sh
# capture:
journalctl -u falco | grep "Shell in container" | tail -1 > /opt/course/falco-hits.txt
```
Gotchas: `container` macro excludes host processes; `spawned_process` = execve. If running Falco via systemd, alerts land in `journalctl -u falco` (and/or `/var/log/syslog`) — grab from whichever your output channel is. Check the rule loaded: `falco --list | grep -i "Shell in container"` or watch startup logs for parse errors.
---
## Task 17 — Audit logging
`/etc/kubernetes/audit/policy.yaml`:
```yaml
apiVersion: audit.k8s.io/v1
kind: Policy
omitStages: ["RequestReceived"]
rules:
- level: RequestResponse
resources:
- group: ""
resources: ["pods"]
verbs: ["create","update","patch","delete"]
- level: Metadata
resources:
- group: ""
resources: ["secrets","configmaps"]
- level: None
```
apiserver manifest — flags:
```yaml
- --audit-policy-file=/etc/kubernetes/audit/policy.yaml
- --audit-log-path=/var/log/kubernetes/audit/audit.log
- --audit-log-maxage=7
- --audit-log-maxbackup=2
- --audit-log-maxsize=50
```
volumes + mounts:
```yaml
# volumes:
- name: audit-policy
hostPath: {path: /etc/kubernetes/audit, type: DirectoryOrCreate}
- name: audit-logs
hostPath: {path: /var/log/kubernetes/audit, type: DirectoryOrCreate}
# volumeMounts:
- name: audit-policy
mountPath: /etc/kubernetes/audit
readOnly: true
- name: audit-logs
mountPath: /var/log/kubernetes/audit
readOnly: false
```
Verify inside the control-plane:
```bash
kubectl -n vault-ns get secret pre-existing
tail -f /var/log/kubernetes/audit/audit.log | grep pre-existing # Metadata level, no body
```
Big gotcha: the audit-logs mount must be `readOnly: false` or the apiserver crashloops silently. And the log dir hostPath is *inside the control-plane container* — that's fine here.
---
### Reset between attempts
```bash
kind delete cluster --name cks
./bootstrap.sh && ./seed.sh
```