kubestronaut-sim/cks/questions.py
vumas3r f3d0e536b6 Exam simulator for CKA, CKAD and CKS with per-task hosts and a remote desktop
kubestronaut-sim runs each exam task on a host of its own - a kind (kubeadm)
cluster in rootless podman, or a QEMU/KVM Ubuntu VM for the CKS tasks that
need their own kernel (Falco, AppArmor) - and puts a browser desktop in front
of them, the way killer.sh and sailor.sh do.

- ksim, sim/: the engine (sessions, drills, study mode, grading, coverage,
  audit), host and VM lifecycle, the desktop
- images/: exam host images (common, CKS variant with its tools, VM guest)
- cka/ (131 tasks), ckad/ (105), cks/ (110): task banks, curricula with
  study notes, reference solutions and scripted solutions

Every domain of every track has at least 17 pool tasks. `./ksim audit` proves
a task three ways: it seeds cleanly on its own host, scores nothing before
anyone has worked on it, and reaches full marks when its scripted solution is
run on the host as `candidate`. All 346 tasks pass (CKA audited 2026-10-13,
CKAD and CKS 2026-10-12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZPd3GGWyxTgAkL6a4gAJz
2026-10-13 16:29:33 -06:00

614 lines
30 KiB
Python

"""CKS track: the track's settings and its first nine tasks. The other 93 are in bank_*.py, one
file per domain, and the objectives they practise in curriculum.py.
CKS hosts are the common exam host plus the tools of the CKS exam (images/node-cks): kube-bench,
trivy, kubesec, kube-linter, bom, cosign, gVisor (runsc), istioctl, and Kubernetes as apt packages.
Two things a container host cannot do, because it shares this machine's kernel under rootless
podman: AppArmor (the kernel has none) and Falco (no eBPF, no kernel module). The tasks for those
(bank_vm.py, "backend": "vm") get an Ubuntu virtual machine as their host instead - images/vm,
built with `./ksim build cks --vm`.
"""
from sim.bank import C, NS, ns_pick, name_pick
TRACK = {
"id": "cks",
"name": "Certified Kubernetes Security Specialist",
"minutes": 120,
"pass_pct": 67,
# Kubernetes minor of the real exam environment, which picks the kind cluster (shared with CKA).
# LF "Important Instructions: CKS", checked 2026-10-02: "The CKS environment is currently running
# Kubernetes v1.35". (cncf/curriculum still names the PDF "CKS_Curriculum v1.34".)
"k8s": "v1.35",
# Worker nodes of a task's cluster unless the task says otherwise ("workers", or WORKERS below)
"workers": 1,
# CKS hosts are the common exam host plus the scanners, gVisor, istioctl and Kubernetes as apt
# packages (images/node-cks)
"image": "cks",
# CKS curriculum, unchanged since the April 2025 revision
"domains": {
"SETUP": "Cluster Setup (15%)",
"HARDEN": "Cluster Hardening (15%)",
"SYS": "System Hardening (10%)",
"MICRO": "Minimize Microservice Vulnerabilities (20%)",
"SUPPLY": "Supply Chain Security (20%)",
"RUNTIME": "Monitoring, Logging and Runtime Security (20%)",
},
# a mock exam of 16 tasks, by domain weight (the real exam has 15-20 in its two hours)
"quota": {"SETUP": 2, "HARDEN": 3, "SYS": 2, "MICRO": 3, "SUPPLY": 3, "RUNTIME": 3},
"quota_hard": {"SETUP": 3, "HARDEN": 3, "SYS": 2, "MICRO": 4, "SUPPLY": 4, "RUNTIME": 4}, # 20, hard ones preferred
}
QUESTIONS = [
# ----------------------------------------------------------------- SETUP ----
{
"id": 1, "domain": "SETUP", "title": "Default-deny, then allow what's needed",
"params": lambda r: {"ns": ns_pick(r, "zone")},
"task": """
Namespace `%{ns}` runs `app=web` (port 8080) and `app=db` (port 5432).
Create four NetworkPolicies:
1. `default-deny` — denies all ingress **and** egress for every Pod in the Namespace.
2. `allow-dns` — lets every Pod reach DNS (UDP and TCP 53) in `kube-system`.
3. `web-to-db` — **egress**: lets `app=web` reach `app=db` on 5432.
4. `db-from-web` — **ingress**: lets `app=db` accept that traffic from `app=web` on 5432.
A NetworkPolicy has exactly one `podSelector`, so the egress half (selecting `web`) and the
ingress half (selecting `db`) cannot live in the same object. Once a default deny is in place
both halves are required — forgetting one is the classic CKS mistake.
(This cluster's kindnet enforces NetworkPolicy, so a gap really drops traffic; you are graded
on the objects.)
""",
"setup": NS + r"""
mkns %{ns}
for a in web db; do
$K -n %{ns} create deploy $a --image=nginx:1.27 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n %{ns} patch deploy $a -p "{\"spec\":{\"template\":{\"metadata\":{\"labels\":{\"app\":\"$a\"}}}}}" >/dev/null
done
$K -n %{ns} delete netpol --all >/dev/null 2>&1
""",
"checks": [
C("default-deny covers all pods, both directions, with no allow rules", 3, r"""
n="$K -n %{ns} get netpol default-deny"
eq "$($n -o jsonpath='{.spec.podSelector}' 2>/dev/null)" '{}' && $n -o jsonpath='{.spec.policyTypes}' | grep -q Ingress \
&& $n -o jsonpath='{.spec.policyTypes}' | grep -q Egress && [ -z "$($n -o jsonpath='{.spec.ingress}')" ] && [ -z "$($n -o jsonpath='{.spec.egress}')" ]"""),
C("allow-dns permits UDP and TCP 53 to kube-system", 3, r"""
$K -n %{ns} get netpol allow-dns -o json 2>/dev/null | python3 -c '
import sys,json
s=json.load(sys.stdin)["spec"]
ok=any({p.get("protocol") for p in r.get("ports",[]) if str(p.get("port"))=="53"}>={"UDP","TCP"}
and r.get("to") and all("kube-system" in json.dumps(t.get("namespaceSelector",{})) for t in r["to"])
for r in s.get("egress",[]))
sys.exit(0 if ok else 1)'"""),
C("web-to-db pairs web egress and db ingress on 5432", 4, r"""
$K -n %{ns} get netpol -o json 2>/dev/null | python3 -c '
import sys,json
eg=ing=False
for n in json.load(sys.stdin)["items"]:
s=n["spec"]; app=s["podSelector"].get("matchLabels",{}).get("app")
for r in s.get("egress",[]):
if app=="web" and any(t.get("podSelector",{}).get("matchLabels",{}).get("app")=="db" for t in r.get("to",[])) \
and any(str(p.get("port"))=="5432" for p in r.get("ports",[])): eg=True
for r in s.get("ingress",[]):
if app=="db" and any(t.get("podSelector",{}).get("matchLabels",{}).get("app")=="web" for t in r.get("from",[])) \
and any(str(p.get("port"))=="5432" for p in r.get("ports",[])): ing=True
sys.exit(0 if eg and ing else 1)'"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: default-deny, namespace: %{ns}}
spec: {podSelector: {}, policyTypes: [Ingress, Egress]}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: allow-dns, namespace: %{ns}}
spec:
podSelector: {}
policyTypes: [Egress]
egress:
- to: [{namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: kube-system}}}]
ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: web-to-db, namespace: %{ns}}
spec:
podSelector: {matchLabels: {app: web}}
policyTypes: [Egress]
egress:
- to: [{podSelector: {matchLabels: {app: db}}}]
ports: [{port: 5432}]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: db-from-web, namespace: %{ns}}
spec:
podSelector: {matchLabels: {app: db}}
policyTypes: [Ingress]
ingress:
- from: [{podSelector: {matchLabels: {app: web}}}]
ports: [{port: 5432}]
EOF
# With a default deny in place, BOTH sides need a rule: egress on the client and ingress on the
# server. Forgetting one is the most common CKS mistake.
""",
},
{
"id": 2, "domain": "SETUP", "title": "Turn off apiserver profiling", "breaks": "apiserver-flag", "difficulty": "hard",
"task": """
An audit found the kube-apiserver's pprof profiling endpoints exposed.
1. Write the current value of the apiserver's `--profiling` setting (or `absent` if the flag is
not set) to `{WS}/q2/before.txt`.
2. Disable profiling on the kube-apiserver, persistently.
3. Confirm the API server comes back healthy and write its `--profiling` value afterwards to
`{WS}/q2/after.txt`.
""",
"setup": r"""
rm -rf "$WS/q2"; mkdir -p "$WS/q2"
nx "$CP" 'sed -i "/--profiling=/d" /etc/kubernetes/manifests/kube-apiserver.yaml' || true
""",
"checks": [
C("before.txt records the original state", 2, r"""grep -Eqi 'absent|true|not set|none' "$WS/q2/before.txt" 2>/dev/null"""),
C("the manifest now sets --profiling=false", 4, r"""nx "$CP" 'grep -q -- "--profiling=false" /etc/kubernetes/manifests/kube-apiserver.yaml'"""),
C("apiserver is healthy again, with profiling off", 4, r"""
nx "$CP" 'grep -q -- "--profiling=false" /etc/kubernetes/manifests/kube-apiserver.yaml' || exit 1
for i in $(seq 1 20); do $K get --raw=/healthz >/dev/null 2>&1 && [ "$(j -n kube-system pod -l component=kube-apiserver '{.items[0].status.phase}')" = Running ] && exit 0; sleep 6; done; exit 1"""),
C("after.txt records false", 1, r"""grep -q 'false' "$WS/q2/after.txt" 2>/dev/null"""),
],
"solution": """
{SHELL_CP}
grep profiling /etc/kubernetes/manifests/kube-apiserver.yaml || echo absent # -> {WS}/q2/before.txt
vi /etc/kubernetes/manifests/kube-apiserver.yaml
# under command:, add - --profiling=false
# the kubelet notices the manifest change and restarts the static pod - watch with:
crictl ps | grep apiserver
exit
k get --raw=/healthz
echo false > {WS}/q2/after.txt
# Editing the manifest is what makes it persistent; `kubectl edit` on the mirror pod would be lost.
# Mind the indentation: the flag goes in the container's command list, not under spec.
""",
},
# ---------------------------------------------------------------- HARDEN ----
{
"id": 3, "domain": "HARDEN", "title": "Replace a wildcard ClusterRole",
"params": lambda r: {"ns": ns_pick(r, "team"), "sa": name_pick(r, "runner")},
"task": """
ServiceAccount `%{sa}` in `%{ns}` is bound to a ClusterRole granting `*` on `*` cluster-wide.
Reduce it to exactly what it needs:
- read-only (`get`, `list`, `watch`) on `pods` and `pods/log` **in `%{ns}` only**.
Use Role `%{sa}-view` and RoleBinding `%{sa}-view`. Remove the over-privileged binding.
The ServiceAccount must have no other permissions anywhere.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} create sa %{sa} --dry-run=client -o yaml | $K apply -f - >/dev/null
$K delete clusterrole %{sa}-god --ignore-not-found >/dev/null; $K delete clusterrolebinding %{sa}-god --ignore-not-found >/dev/null
$K -n %{ns} delete role,rolebinding --all >/dev/null 2>&1
$K create clusterrole %{sa}-god --verb='*' --resource='*.*' >/dev/null
$K create clusterrolebinding %{sa}-god --clusterrole=%{sa}-god --serviceaccount=%{ns}:%{sa} >/dev/null
""",
"checks": [
C("the wildcard binding is gone", 3, r"""! $K get clusterrolebinding %{sa}-god >/dev/null 2>&1"""),
C("can get/list/watch pods and pods/log in %{ns}, without the wildcard binding", 3, r"""
! $K get clusterrolebinding %{sa}-god >/dev/null 2>&1 || exit 1
# to `can-i`, "pods/log" would be a Pod named log: the subresource is asked for with --subresource
for sub in "" --subresource=log; do for v in get list watch; do
eq "$($K auth can-i $v pods $sub -n %{ns} --as=system:serviceaccount:%{ns}:%{sa} 2>/dev/null)" yes || exit 1
done; done"""),
C("nothing else: no delete pods, no secrets, nothing in other namespaces", 4, r"""
for t in "delete pods -n %{ns}" "get secrets -n %{ns}" "create pods -n %{ns}" "list pods -n default" "list nodes"; do
eq "$($K auth can-i $t --as=system:serviceaccount:%{ns}:%{sa} 2>/dev/null || true)" no || exit 1
done"""),
],
"solution": """
k get clusterrolebinding -o wide | grep %{sa}
k delete clusterrolebinding %{sa}-god
k -n %{ns} create role %{sa}-view --verb=get,list,watch --resource=pods,pods/log
k -n %{ns} create rolebinding %{sa}-view --role=%{sa}-view --serviceaccount=%{ns}:%{sa}
k auth can-i --list -n %{ns} --as=system:serviceaccount:%{ns}:%{sa}
# Deleting the ClusterRole itself is optional - the binding is what grants the access.
""",
},
{
"id": 4, "domain": "HARDEN", "title": "Stop handing out API tokens",
"params": lambda r: {"ns": ns_pick(r, "apps"), "dep": name_pick(r, "worker")},
"task": """
In Namespace `%{ns}`:
1. Make the **default** ServiceAccount stop mounting its API token into Pods automatically.
2. Deployment `%{dep}` genuinely needs API access: give it a dedicated ServiceAccount `%{dep}-sa`
that does mount its token, and make the Deployment use it.
3. Confirm: a new Pod using the default ServiceAccount has no token mounted, while `%{dep}`'s Pods do.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} patch sa default -p '{"automountServiceAccountToken":null}' >/dev/null 2>&1 || true
$K -n %{ns} delete sa %{dep}-sa --ignore-not-found >/dev/null
$K -n %{ns} delete deploy %{dep} --ignore-not-found >/dev/null
$K -n %{ns} delete pod plain --ignore-not-found --wait=false >/dev/null
$K -n %{ns} create deploy %{dep} --image=nginx:1.27 >/dev/null
""",
"checks": [
C("default ServiceAccount has automountServiceAccountToken: false", 3, r"""eq "$(j -n %{ns} sa default '{.automountServiceAccountToken}')" false"""),
C("%{dep} uses %{dep}-sa, which does mount a token", 3, r"""
eq "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.serviceAccountName}')" %{dep}-sa \
&& [ "$(j -n %{ns} sa %{dep}-sa '{.automountServiceAccountToken}')" != false ]"""),
C("%{dep} pods have a projected token mounted", 3, r"""
p=$(j -n %{ns} pod -l app=%{dep} '{.items[0].metadata.name}'); [ -n "$p" ] || exit 1
$K -n %{ns} exec "$p" -- test -f /var/run/secrets/kubernetes.io/serviceaccount/token 2>/dev/null"""),
C("a pod on the default ServiceAccount has no token", 3, r"""
cleanup() { $K -n %{ns} delete pod plain --wait=false >/dev/null 2>&1; }
$K -n %{ns} delete pod plain --ignore-not-found --wait=true >/dev/null 2>&1
$K -n %{ns} run plain --image=busybox:1.36 --restart=Never -- sleep 120 >/dev/null 2>&1
ok=0; for i in $(seq 1 20); do [ "$(j -n %{ns} pod plain '{.status.phase}')" = Running ] && { ok=1; break; }; sleep 3; done
# A probe pod that never ran proves nothing. Treating a failed exec as "no token mounted" handed
# out these points for doing absolutely nothing whenever pod creation was blocked or slow.
[ "$ok" = 1 ] || { cleanup; exit 1; }
$K -n %{ns} exec plain -- true >/dev/null 2>&1 || { cleanup; exit 1; }
r=1; $K -n %{ns} exec plain -- test -f /var/run/secrets/kubernetes.io/serviceaccount/token >/dev/null 2>&1 || r=0
cleanup; exit $r"""),
],
"solution": """
k -n %{ns} patch sa default -p '{"automountServiceAccountToken": false}'
k -n %{ns} create sa %{dep}-sa
k -n %{ns} set serviceaccount deploy %{dep} %{dep}-sa
k -n %{ns} rollout status deploy %{dep}
# The setting can also be put on the Pod spec (automountServiceAccountToken: false), which
# overrides the ServiceAccount - useful when only one workload should differ.
""",
},
# ------------------------------------------------------------------- SYS ----
{
"id": 5, "domain": "SYS", "title": "seccomp and a minimal container",
"params": lambda r: {"ns": ns_pick(r, "sys"), "pod": name_pick(r, "confined")},
"task": """
Create Pod `%{pod}` in `%{ns}` (image `nginx:1.27`) that:
- uses the **RuntimeDefault** seccomp profile at Pod level,
- runs as a non-root user (UID 101, nginx's own user) with `allowPrivilegeEscalation: false`,
- drops all capabilities and adds back only `NET_BIND_SERVICE` and `CHOWN`,
- mounts an `emptyDir` at `/var/cache/nginx` and one at `/var/run` so nginx can still write
while the root filesystem is read-only.
The Pod must reach Running.
""",
"setup": NS + r"""mkns %{ns}; $K -n %{ns} delete pod %{pod} --ignore-not-found --wait=false >/dev/null""",
"checks": [
C("seccompProfile RuntimeDefault at pod level", 3, r"""eq "$(j -n %{ns} pod %{pod} '{.spec.securityContext.seccompProfile.type}')" RuntimeDefault"""),
C("non-root UID 101, no privilege escalation, read-only root fs", 3, r"""
p="$K -n %{ns} get pod %{pod}"
u=$($p -o jsonpath='{.spec.securityContext.runAsUser}' 2>/dev/null); [ -z "$u" ] && u=$($p -o jsonpath='{.spec.containers[0].securityContext.runAsUser}')
eq "$u" 101 && eq "$($p -o jsonpath='{.spec.containers[0].securityContext.allowPrivilegeEscalation}')" false \
&& eq "$($p -o jsonpath='{.spec.containers[0].securityContext.readOnlyRootFilesystem}')" true"""),
C("capabilities drop ALL, add NET_BIND_SERVICE and CHOWN", 3, r"""
c='{.spec.containers[0].securityContext.capabilities'
j -n %{ns} pod %{pod} "$c.drop[*]}" | grep -q ALL && j -n %{ns} pod %{pod} "$c.add[*]}" | grep -q NET_BIND_SERVICE && j -n %{ns} pod %{pod} "$c.add[*]}" | grep -q CHOWN"""),
C("pod Running with both writable mounts", 3, r"""
eq "$(j -n %{ns} pod %{pod} '{.status.phase}')" Running \
&& j -n %{ns} pod %{pod} '{.spec.containers[0].volumeMounts[*].mountPath}' | grep -q '/var/cache/nginx' \
&& j -n %{ns} pod %{pod} '{.spec.containers[0].volumeMounts[*].mountPath}' | grep -q '/var/run'"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
securityContext:
seccompProfile: {type: RuntimeDefault}
runAsUser: 101
runAsNonRoot: true
volumes:
- {name: cache, emptyDir: {}}
- {name: run, emptyDir: {}}
containers:
- name: web
image: nginx:1.27
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: [ALL], add: [NET_BIND_SERVICE, CHOWN]}
volumeMounts:
- {name: cache, mountPath: /var/cache/nginx}
- {name: run, mountPath: /var/run}
EOF
# readOnlyRootFilesystem breaks most images until you mount emptyDirs over the paths they write to;
# for nginx those are /var/cache/nginx and /var/run.
""",
},
# ----------------------------------------------------------------- MICRO ----
{
"id": 6, "domain": "MICRO", "title": "Enforce Pod Security restricted",
"params": lambda r: {"ns": ns_pick(r, "psa"), "dep": name_pick(r, "legacy")},
"task": """
Namespace `%{ns}` runs Deployment `%{dep}`, whose Pods run as root and request extra privileges.
1. Label the Namespace so the **restricted** Pod Security Standard is **enforced** (latest version),
and also **warn** and **audit** at restricted.
2. Fix `%{dep}` so its Pods are admitted under that standard, without changing its image
(`nginx:1.27`) or replica count.
All replicas must be Available afterwards.
""",
"setup": NS + r"""
mkns %{ns}
$K label ns %{ns} pod-security.kubernetes.io/enforce- pod-security.kubernetes.io/warn- pod-security.kubernetes.io/audit- >/dev/null 2>&1 || true
$K -n %{ns} delete deploy %{dep} --ignore-not-found --wait=true >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{dep}, namespace: %{ns}}
spec:
replicas: 2
selector: {matchLabels: {app: %{dep}}}
template:
metadata: {labels: {app: %{dep}}}
spec:
volumes: [{name: cache, emptyDir: {}}, {name: run, emptyDir: {}}]
containers:
- name: web
image: nginx:1.27
securityContext: {privileged: true}
volumeMounts: [{name: cache, mountPath: /var/cache/nginx}, {name: run, mountPath: /var/run}]
EOF
$K -n %{ns} rollout status deploy %{dep} --timeout=90s >/dev/null 2>&1
""",
"checks": [
C("namespace enforces, warns and audits restricted", 3, r"""
l=$(j ns %{ns} '{.metadata.labels}')
echo "$l" | grep -q '"pod-security.kubernetes.io/enforce":"restricted"' && echo "$l" | grep -q '"pod-security.kubernetes.io/warn":"restricted"' && echo "$l" | grep -q '"pod-security.kubernetes.io/audit":"restricted"'"""),
C("deployment no longer asks for privilege", 3, r"""
t=$(j -n %{ns} deploy %{dep} '{.spec.template.spec}')
! echo "$t" | grep -q '"privileged":true' && echo "$t" | grep -q '"allowPrivilegeEscalation":false' && echo "$t" | grep -q 'RuntimeDefault'"""),
C("it satisfies restricted: non-root, drop ALL", 3, r"""
t=$(j -n %{ns} deploy %{dep} '{.spec.template.spec}')
echo "$t" | grep -q '"runAsNonRoot":true' && echo "$t" | grep -q '"drop":\["ALL"\]'"""),
C("2 replicas Available under enforcement, image unchanged", 4, r"""
j ns %{ns} '{.metadata.labels}' | grep -q '"pod-security.kubernetes.io/enforce":"restricted"' || exit 1
eq "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.containers[0].image}')" nginx:1.27
for i in $(seq 1 12); do [ "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" = 2 ] && exit 0; sleep 5; done; exit 1"""),
],
"solution": """
k label ns %{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
k -n %{ns} rollout restart deploy %{dep} # now blocked, see the events for the exact reasons
k -n %{ns} get events --field-selector reason=FailedCreate
k -n %{ns} edit deploy %{dep} # pod template needs, at minimum:
# securityContext: {runAsNonRoot: true, runAsUser: 101, seccompProfile: {type: RuntimeDefault}}
# container securityContext: {allowPrivilegeEscalation: false, capabilities: {drop: [ALL]}}
# and remove privileged: true
k -n %{ns} rollout status deploy %{dep}
# restricted requires all four: runAsNonRoot, allowPrivilegeEscalation=false, drop ALL,
# and a RuntimeDefault (or Localhost) seccomp profile.
""",
},
{
"id": 7, "domain": "MICRO", "title": "Isolate a workload's ServiceAccount and namespace",
"params": lambda r: {"ns": ns_pick(r, "tenant"), "pod": name_pick(r, "isolated")},
"task": """
Create Pod `%{pod}` in Namespace `%{ns}` (image `busybox:1.36`, `sleep 86400`) that:
- uses ServiceAccount `%{pod}-sa` (create it),
- does **not** get an API token mounted, set on the **Pod**, not the ServiceAccount,
- does not use the host network, PID or IPC namespaces (set `hostNetwork`, `hostPID` and
`hostIPC` to `false` - the API stores `false` as unset, so the check is that none is `true`),
- has both CPU and memory requests and limits set (any sane values).
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete pod %{pod} --ignore-not-found --wait=true --grace-period=1 >/dev/null
$K -n %{ns} delete sa %{pod}-sa --ignore-not-found >/dev/null
""",
"checks": [
C("uses its own ServiceAccount", 2, r"""$K -n %{ns} get sa %{pod}-sa >/dev/null 2>&1 && eq "$(j -n %{ns} pod %{pod} '{.spec.serviceAccountName}')" %{pod}-sa"""),
C("token not mounted, set on the pod spec", 3, r"""
eq "$(j -n %{ns} pod %{pod} '{.spec.automountServiceAccountToken}')" false \
&& ! $K -n %{ns} exec %{pod} -- test -f /var/run/secrets/kubernetes.io/serviceaccount/token 2>/dev/null"""),
# hostNetwork/hostPID/hostIPC are omitempty booleans: an explicit false is never stored, so the
# most a check can prove is that the Pod exists and none of them is true
C("no host network, PID or IPC namespace", 3, r"""
p="$K -n %{ns} get pod %{pod}"
$p >/dev/null 2>&1 || exit 1
for f in hostNetwork hostPID hostIPC; do [ "$($p -o jsonpath="{.spec.$f}")" != true ] || exit 1; done"""),
C("requests and limits set for cpu and memory", 2, r"""
c='{.spec.containers[0].resources'
for f in requests.cpu requests.memory limits.cpu limits.memory; do [ -n "$(j -n %{ns} pod %{pod} "$c.$f}")" ] || exit 1; done"""),
],
"solution": """
k -n %{ns} create sa %{pod}-sa
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
serviceAccountName: %{pod}-sa
automountServiceAccountToken: false
hostNetwork: false
hostPID: false
hostIPC: false
containers:
- name: c
image: busybox:1.36
command: [sleep, "86400"]
resources:
requests: {cpu: 50m, memory: 64Mi}
limits: {cpu: 200m, memory: 128Mi}
EOF
k -n %{ns} exec %{pod} -- ls /var/run/secrets/kubernetes.io/ 2>&1 || echo "no token, as intended"
""",
},
# ---------------------------------------------------------------- SUPPLY ----
{
"id": 8, "domain": "SUPPLY", "title": "Admission policy for allowed registries", "difficulty": "hard",
"params": lambda r: {"ns": ns_pick(r, "trusted"), "reg": r.choice(["registry.internal.io", "harbor.corp.io"])},
"task": """
Only images from `%{reg}/` may run in Namespace `%{ns}`.
Using **ValidatingAdmissionPolicy** (no third-party admission controller):
1. Create a `ValidatingAdmissionPolicy` named `allowed-registry` that denies any Pod whose
containers are not all from `%{reg}/`.
2. Bind it with a `ValidatingAdmissionPolicyBinding` named `allowed-registry` that enforces
(`validationActions: [Deny]`) on Namespace `%{ns}` only.
3. Prove it: creating a Pod from `nginx:1.27` in `%{ns}` must be rejected. Save the rejection
message to `{WS}/q8/denied.txt`.
""",
"setup": NS + r"""
mkns %{ns}
rm -rf "$WS/q8"; mkdir -p "$WS/q8"
$K label ns %{ns} registry-policy=enforced --overwrite >/dev/null
$K delete validatingadmissionpolicybinding allowed-registry --ignore-not-found >/dev/null 2>&1
$K delete validatingadmissionpolicy allowed-registry --ignore-not-found >/dev/null 2>&1
""",
"checks": [
C("ValidatingAdmissionPolicy allowed-registry exists and checks image prefixes", 3, r"""
v=$(j validatingadmissionpolicy allowed-registry '{.spec.validations[*].expression}' 2>/dev/null)
[ -n "$v" ] && echo "$v" | grep -q '%{reg}' && j validatingadmissionpolicy allowed-registry '{.spec.matchConstraints.resourceRules[*].resources[*]}' | grep -q pods"""),
C("binding enforces Deny and is scoped to %{ns}", 3, r"""
b="$K get validatingadmissionpolicybinding allowed-registry"
eq "$($b -o jsonpath='{.spec.policyName}' 2>/dev/null)" allowed-registry && $b -o jsonpath='{.spec.validationActions[*]}' | grep -q Deny \
&& $b -o jsonpath='{.spec.matchResources}' | grep -Eq '%{ns}|registry-policy'"""),
C("a disallowed image is actually rejected", 4, r"""
out=$($K -n %{ns} run vap-probe --image=nginx:1.27 --restart=Never 2>&1); rc=$?
$K -n %{ns} delete pod vap-probe --ignore-not-found --wait=false >/dev/null 2>&1
[ $rc -ne 0 ] && echo "$out" | grep -qi 'denied\|not allowed\|failed'"""),
C("denied.txt captures the rejection", 2, r"""grep -Eqi 'denied|not allowed|admission' "$WS/q8/denied.txt" 2>/dev/null"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata: {name: allowed-registry}
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: [CREATE, UPDATE]
resources: ["pods"]
validations:
- expression: "object.spec.containers.all(c, c.image.startsWith('%{reg}/'))"
message: "images must come from %{reg}/"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata: {name: allowed-registry}
spec:
policyName: allowed-registry
validationActions: [Deny]
matchResources:
namespaceSelector:
matchLabels: {registry-policy: enforced}
EOF
k -n %{ns} run vap-probe --image=nginx:1.27 2>&1 | tee {WS}/q8/denied.txt # rejected
# CEL runs in-process in the apiserver - no webhook to deploy, no certificate to manage.
# Add initContainers to the expression too if the exam asks for every container.
""",
},
# --------------------------------------------------------------- RUNTIME ----
{
"id": 9, "domain": "RUNTIME", "title": "Turn on API audit logging", "breaks": "apiserver-flag", "difficulty": "hard",
"task": """
The cluster has no audit trail.
On the control-plane node:
1. Write an audit policy to `/etc/kubernetes/audit-policy.yaml` that logs Secret access at
`Metadata` level and everything else at `Metadata` too, but drops the noisy
`RequestReceived` stage.
2. Configure the kube-apiserver to use it, writing to `/var/log/kubernetes/audit.log`, keeping
7 days of logs and at most 5 rotated files.
3. Bring the API server back healthy, then generate an event (`kubectl get secrets -A`) and copy
the last audit line mentioning `secrets` to `{WS}/q9/audit-line.json`.
""",
"setup": r"""
rm -rf "$WS/q9"; mkdir -p "$WS/q9"
# Back to "no audit trail" without bricking the API server: a previous answer may have left a hostPath
# volume of type File for the policy, so leave a do-nothing policy in its place (check 1 still fails
# until the candidate writes theirs), drop the old log so it cannot pass check 3, and edit the manifest
# through a temp file outside manifests/ - a sed -i temp file there is loaded as a second static pod.
nx "$CP" 'f=/etc/kubernetes/manifests/kube-apiserver.yaml
if grep -q -- "--audit-" $f; then t=$(mktemp /etc/kubernetes/.q9.XXXXXX) && sed "/--audit-/d" $f > $t && chmod --reference=$f $t && mv -f $t $f; fi
printf "apiVersion: audit.k8s.io/v1\nkind: Policy\nrules:\n- level: None\n" > /etc/kubernetes/audit-policy.yaml
rm -f /var/log/kubernetes/audit*.log' || true
""",
"checks": [
C("audit policy file exists and omits RequestReceived", 3, r"""
nx "$CP" 'test -f /etc/kubernetes/audit-policy.yaml' && nx "$CP" 'grep -q "omitStages" /etc/kubernetes/audit-policy.yaml' \
&& nx "$CP" 'grep -q "RequestReceived" /etc/kubernetes/audit-policy.yaml'"""),
C("apiserver flags: policy file, log path, maxage 7, maxbackup 5", 4, r"""
m=$(nx "$CP" 'cat /etc/kubernetes/manifests/kube-apiserver.yaml')
echo "$m" | grep -q -- '--audit-policy-file=/etc/kubernetes/audit-policy.yaml' \
&& echo "$m" | grep -q -- '--audit-log-path=/var/log/kubernetes/audit.log' \
&& echo "$m" | grep -q -- '--audit-log-maxage=7' && echo "$m" | grep -q -- '--audit-log-maxbackup=5'"""),
C("apiserver healthy and writing the log", 4, r"""
for i in $(seq 1 20); do $K get --raw=/healthz >/dev/null 2>&1 && break; sleep 6; done
$K get --raw=/healthz >/dev/null 2>&1 || exit 1
$K get secrets -A >/dev/null 2>&1
for i in $(seq 1 6); do nx "$CP" 'test -s /var/log/kubernetes/audit.log' && exit 0; sleep 5; done; exit 1"""),
C("audit-line.json is a real audit event about secrets", 3, r"""
python3 -c '
import json,sys
d=json.load(open("'"$WS"'/q9/audit-line.json"))
sys.exit(0 if d.get("kind")=="Event" and "secrets" in json.dumps(d) else 1)' 2>/dev/null"""),
],
"solution": """
{SHELL_CP}
mkdir -p /var/log/kubernetes
cat > /etc/kubernetes/audit-policy.yaml <<'EOF'
apiVersion: audit.k8s.io/v1
kind: Policy
omitStages:
- RequestReceived
rules:
- level: Metadata
resources:
- group: ""
resources: ["secrets"]
- level: Metadata
EOF
vi /etc/kubernetes/manifests/kube-apiserver.yaml
# command:
# - --audit-policy-file=/etc/kubernetes/audit-policy.yaml
# - --audit-log-path=/var/log/kubernetes/audit.log
# - --audit-log-maxage=7
# - --audit-log-maxbackup=5
# volumeMounts:
# - {name: audit-policy, mountPath: /etc/kubernetes/audit-policy.yaml, readOnly: true}
# - {name: audit-log, mountPath: /var/log/kubernetes}
# volumes:
# - {name: audit-policy, hostPath: {path: /etc/kubernetes/audit-policy.yaml, type: File}}
# - {name: audit-log, hostPath: {path: /var/log/kubernetes, type: DirectoryOrCreate}}
exit
k get secrets -A
# grab the last secrets line:
# grep secrets /var/log/kubernetes/audit.log | tail -1 -> {WS}/q9/audit-line.json
# Forgetting the two hostPath mounts is the usual reason the apiserver crashloops here:
# the flags point at paths that don't exist inside the container.
""",
},
]
# 10-24: Cluster Setup up to 17 pool tasks (bank_setup.py).
# 25-39: Cluster Hardening up to 17 pool tasks (bank_harden.py).
# 40-55: System Hardening up to 17 pool tasks (bank_sys.py).
# 56-70: Minimize Microservice Vulnerabilities up to 17 pool tasks (bank_micro.py).
# 71-86: Supply Chain Security up to 17 pool tasks (bank_supply.py).
# 87-102: Monitoring, Logging and Runtime Security up to 17 pool tasks (bank_runtime.py).
# 103-110: Falco and AppArmor, on a VM host (bank_vm.py) - out of every session until that is built.
from cks import bank_harden, bank_micro, bank_runtime, bank_setup, bank_supply, bank_sys, bank_vm # noqa: E402
QUESTIONS = (QUESTIONS + bank_setup.QUESTIONS + bank_harden.QUESTIONS + bank_sys.QUESTIONS + bank_micro.QUESTIONS
+ bank_supply.QUESTIONS + bank_runtime.QUESTIONS + bank_vm.QUESTIONS)
# Tasks held back from every session: id -> why.
PENDING = {}
# Worker nodes of a task's cluster where the task does not say so itself: id -> n.
WORKERS = {}