cka-sim/tracks/cks/questions.py
vumast3r 49fcb76166 fix: taint cleanup removed nothing, Q23 leaked a broken metrics-server, CKS lost 40% of its blueprint
Audit of the bank and harness after a 23% mock result. Two of these were
silently corrupting every practice session on this cluster.

restore_baseline:

  - The w1-taint cleanup removed nothing at all. `kubectl taint node N a- b- c-`
    aborts on the first key that is not present and applies none of the removals,
    so with only `workload` set the command reported five "not found" lines,
    exited 1, and left the taint in place - the precise failure the block's own
    comment says it exists to prevent. W1 then rejected pods in every later
    session. Now one call per key. Verified by re-seeding workload=gpu:NoSchedule
    and confirming a single restore_baseline run clears it.
  - metrics-server was never repaired. Q23 seeds its scenario by stripping
    --kubelet-insecure-tls, but carried no "breaks" key, so nothing tracked it
    and nothing undid it. On kind the kubelet serving certs have no IP SANs, so
    without that flag metrics-server never goes Ready, v1beta1.metrics.k8s.io
    has no endpoints, aggregated discovery goes stale, and EVERY namespace
    deletion in the cluster hangs on NamespaceDeletionDiscoveryFailure - which
    is where the ~40 stuck namespaces came from, plus a dead `kubectl top`.
    Q23 now declares breaks: metrics-server and the baseline restores the flag.
    Baseline runs before seeding, so a session containing Q23 re-breaks it a
    moment later as intended. Verified by stripping the flag and confirming one
    restore_baseline run puts it back.

Session composition:

  - pick() dropped any domain whose tasks are all "hard" from a normal session.
    CKS SUPPLY and RUNTIME hold one hard task each, so `./exam start` returned
    6 of 9 tasks with 40% of the blueprint silently absent. It now tops up from
    the hard pool when a domain's normal pool is short.
  - compose_exam() iterated QUOTA in declaration order, so a one-task domain
    lost every "breaks" tie to a bigger domain that claimed the shared slot
    first: CKS RUNTIME (only Q9, apiserver-flag) was never drawn in ANY mode
    because SETUP went first and took that slot with Q2. Now walks domains
    scarcest-first, and warns on a shortfall instead of truncating in silence.
    CKS now yields 8/9 covering all six domains, warning SETUP 1/2.
  - session_questions() rebuilt the list by filtering QUESTIONS (sorted by id),
    discarding the session's own order - so compose_exam's rng.shuffle never
    reached the candidate and every exam arrived grouped by domain and
    difficulty, base tasks then hard then sim sets. Fixed to preserve the stored
    order, and cmd_drill now shuffles explicit-id drills after conflict
    resolution, so which task gets dropped stays deterministic while the order
    does not.

Bank correctness (verified by grading each reference solution):

  - CKAD Q2 was unpassable, 5/8. Its init container waited on `until nslookup
    backend`, but busybox nslookup exits 1 even on a successful lookup because
    it walks the whole search list and the trailing NXDOMAINs set the status -
    the loop never terminates. The premise was wrong too: a ClusterIP Service
    resolves from creation, endpoints or not, so DNS could never have gated it.
    Now waits on a real connection; 8/8, and the pod genuinely holds at Init:0/1
    until the backend Deployment exists. Same bug class as e3f5ecd.
  - CKS Q1 was unsatisfiable as written, 6/10. The text asked for three
    NetworkPolicies but the grader required a fourth: one podSelector cannot
    cover both the web egress half and the db ingress half. Task text now asks
    for four and says why.
  - CKS Q4 check 4 was a false positive. `... || r=0` read a failed exec as
    "no token mounted", awarding 3 points when the candidate had done nothing
    and the probe pod simply could not start - which happens often on this
    cluster. Now requires the pod to reach Running and exec to work. Verified
    it awards the points before the fix and fails after, with pod creation
    blocked by a quota.
  - CKAD Q8's solution asserted a Pod's image cannot be changed in place.
    It can - spec.containers[].image is mutable; `kubectl set image pod/...`
    works and grades 7/7. Rewritten, noting which fields really are immutable.

Also: the `sim` usage and help strings hardcoded "a|b" and a "17-task" count,
wrong for a third set and wrong for CKAD/CKS, which define no sets at all. Both
are now generated from SETS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NziZdvBvy7CpEAAbZNgp8i
2026-09-18 09:05:03 -06:00

568 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 - kindnet does not enforce NetworkPolicy, and there is no
Falco, AppArmor profile loader, gVisor runtime or image scanner installed. 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.
(kindnet does not enforce policies; 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.
""",
},
]