cka-sim/tracks/cks/questions.py
vumast3r 55ab18f814 fix: repair the CKA bank and harness after a live audit; add Simulator Set D
Every changed task was seeded, graded untouched, solved and re-graded on a
separate kind cluster. The four sim sets were rehearsed as full sittings (real
baseline, seed everything, solve in order, grade at the end): A 150/150,
B 176/176, C 163/163, D 122/122. Every random-pool task was either passed in a
real session or cycled the same way.

Graders that could never pass, or passed for free:
  Q306 (local-path cannot resize; now graded as an in-place resize), Q314 (GNU
  grep prefixed file names), Q305 (plain `get events` has no SOURCE column and
  events expire; now managedFields), Q410 (kindnet DOES enforce NetworkPolicy;
  the expected answer is now derived from the CNI), Q35, Q212 (missing `]`),
  Q101 (`can-i create pods/exec` asks about a pod NAMED exec; multi-type get),
  Q109 (an emptyDir over nginx's /var/log crash-loops it), Q40 (TTL was
  measured at grading time), Q121, Q106, Q104, Q41, Q26, Q403, Q409, Q417,
  Q113, Q22, Q24/Q108 (flaky right after a rollout), and free points on
  untouched Q24/Q102/Q108/Q119/Q122.

Reference solutions: S[43], S[107], S[121], S[106] rewritten; S[116] (a
Completed Job pod still pins its PVC), S[119] (a pods quota equal to replicas
deadlocks the rollout); DNS checks use FQDNs; the explainer and exam guide
are corrected (etcd restore is etcdutl's job on 3.6+).

Cross-session leaks: the pre-session baseline now also repairs Q114 (the
kube-system NetworkPolicy and CoreDNS replicas), Q117/Q39 kubelet CA paths,
Q317, kubelet drop-in binary paths, a moved scheduler manifest, leftover
static pods and the default StorageClass. Q115 releases its fixed PVs from
claims left by earlier sessions.

Conflicts: `breaks` may be a list and `solo` keeps Q102's etcd restore out of
mixed sessions. Q118, Q5/Q103, Q26 vs the W1 taints, set A 203/204/208/212 and
set B 305/309 no longer sabotage each other. Q317 now disables only CSR
signing (the old break killed the whole controller-manager). Q110/Q311 no
longer compare against the whole cluster at submit time. Two delete/recreate
races in setups are fixed.

Harness: the weak list clears after two clean passes in a row, weak entries
show the latest attempt's failures, domain scores use the exam's own tasks,
history entries can be marked synthetic (entry 11 is), the web UI lists
every sim set, `./exam grade` refuses mid-exam (--force), preflight checks W1.

Set D (`./exam sim d`): 17 original killer.sh-style tasks, ids 501-517,
122 points - control-plane inventory, event forensics with crictl, a broken
kubelet unit, kubelet certificates, manual scheduling with the scheduler
stopped, Helm housekeeping with a stuck release, an enforced AND-vs-OR
NetworkPolicy, dnsPolicy and access-mode troubleshooting, and more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 21:59:35 -06:00

569 lines
27 KiB
Python

"""CKS track. Starter bank.
Caveat worth knowing before you rely on this: several CKS objectives need infrastructure the
default kind cluster does not have - there is no Falco, AppArmor profile loader, gVisor runtime
or image scanner installed. (NetworkPolicy IS enforced: recent kindnet embeds a policy engine.) Tasks here either
stick to what a stock kubeadm/kind cluster can really do (PSA, RBAC, admission policies, audit
logging, seccomp, token hygiene) or are graded on the object rather than on enforcement, and say so.
"""
from tracks.cka.common import C, NS, ns_pick, name_pick
TRACK = {
"id": "cks",
"name": "Certified Kubernetes Security Specialist",
"minutes": 120,
"pass_pct": 67,
"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%)",
},
"quota": {"SETUP": 2, "HARDEN": 2, "SYS": 1, "MICRO": 2, "SUPPLY": 1, "RUNTIME": 1},
"quota_hard": {"SETUP": 2, "HARDEN": 2, "SYS": 1, "MICRO": 2, "SUPPLY": 1, "RUNTIME": 1},
}
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", 4, r"""
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}", 3, r"""
for res in pods pods/log; do for v in get list watch; do
eq "$($K auth can-i $v $res -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"""
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,
- runs with `hostNetwork`, `hostPID` and `hostIPC` all explicitly disabled,
- 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=false >/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"""),
C("host namespaces explicitly disabled", 3, r"""
p="$K -n %{ns} get pod %{pod}"
eq "$($p -o jsonpath='{.spec.hostNetwork}' 2>/dev/null)" false && eq "$($p -o jsonpath='{.spec.hostPID}')" false && eq "$($p -o jsonpath='{.spec.hostIPC}')" false"""),
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"
nx "$CP" 'sed -i "/--audit-/d" /etc/kubernetes/manifests/kube-apiserver.yaml; rm -f /etc/kubernetes/audit-policy.yaml' || 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.
""",
},
]