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
568 lines
27 KiB
Python
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.
|
|
""",
|
|
},
|
|
]
|