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