cka-sim/tracks/cks/questions.py

585 lines
28 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,
# 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",
# 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%)",
},
"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,
- 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.
""",
},
]