"""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 cluster, which picks the kind node image (shared with CKA). Ahead of # the real exam on purpose: LF "Important Instructions: CKS", checked 2026-10-19, still says "The # CKS environment is currently running Kubernetes v1.35", but CKA and CKAD are on v1.37 and # CKS is expected to follow by the time it is taken. (cncf/curriculum still names the PDF # "CKS_Curriculum v1.34".) "k8s": "v1.37", # 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 </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 run as %{dep}-sa, with a projected token mounted", 3, r""" # a Pod of the new template: untouched, the Deployment's Pods run as default - with a token p=$(j -n %{ns} pod -l app=%{dep} '{.items[?(@.spec.serviceAccountName=="%{dep}-sa")].metadata.name}' | awk '{print $1}'); [ -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 </dev/null 2>&1 || true $K -n %{ns} delete deploy %{dep} --ignore-not-found --wait=true >/dev/null $K apply -f - >/dev/null </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 <&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 <&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 = {}