kubestronaut-sim/cks/bank_micro.py
vumas3r f3d0e536b6 Exam simulator for CKA, CKAD and CKS with per-task hosts and a remote desktop
kubestronaut-sim runs each exam task on a host of its own - a kind (kubeadm)
cluster in rootless podman, or a QEMU/KVM Ubuntu VM for the CKS tasks that
need their own kernel (Falco, AppArmor) - and puts a browser desktop in front
of them, the way killer.sh and sailor.sh do.

- ksim, sim/: the engine (sessions, drills, study mode, grading, coverage,
  audit), host and VM lifecycle, the desktop
- images/: exam host images (common, CKS variant with its tools, VM guest)
- cka/ (131 tasks), ckad/ (105), cks/ (110): task banks, curricula with
  study notes, reference solutions and scripted solutions

Every domain of every track has at least 17 pool tasks. `./ksim audit` proves
a task three ways: it seeds cleanly on its own host, scores nothing before
anyone has worked on it, and reaches full marks when its scripted solution is
run on the host as `candidate`. All 346 tasks pass (CKA audited 2026-10-13,
CKAD and CKS 2026-10-12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZPd3GGWyxTgAkL6a4gAJz
2026-10-13 16:29:33 -06:00

1056 lines
58 KiB
Python

"""CKS tasks 56-70: Minimize Microservice Vulnerabilities - the pool tasks that take the domain from 2 to 17.
4.1 Pod Security 56 enforce baseline, and who breaks it 57 a default for the whole cluster
Standards 58 a Deployment that restricted refuses 59 the strictest level each Namespace passes
4.2 Secrets 60 encrypt Secrets at rest 61 read a Secret straight from etcd
62 from environment variables to files 63 secrets in a ConfigMap
64 rotate the encryption key
4.3 Isolation 65 run a workload in gVisor 66 fence a tenant in
67 workloads that reach into the node
4.4 Pod-to-Pod 68 mutual TLS for a Namespace (Istio) 69 strict for the mesh, one port excepted
encryption 70 mutual authentication in a CiliumNetworkPolicy
Admission is tested by asking the API server to admit Pods (server-side dry run), encryption by
reading etcd, gVisor by the sandbox's own kernel log, and Istio's mutual TLS with plain-text
requests from outside the mesh: CKS hosts carry gVisor (runsc) and istioctl, and 68-69 install
Istio on the task's cluster. No Cilium agent runs here - kindnet is the CNI - so 70 is graded on
the object. Every task has a scripted solution in solve.py.
"""
from cks.common import API, C, NET, PODS, PY, NS, ns_pick, name_pick
# Would the API server admit a Pod to a Namespace? (server-side dry run - nothing is created)
PSA = r"""
admits() { local ns=$1; shift; $K -n "$ns" run ksim-psa-probe --image=busybox:1.36 --dry-run=server "$@" -- sleep 1 >/dev/null 2>&1; }
# (the refusal is kubectl failing: its output is looked at afterwards, not through a pipe, which
# `pipefail` would report as a failure of the whole check)
refuses() { local ns=$1 out; shift; out=$($K -n "$ns" run ksim-psa-probe --image=busybox:1.36 --dry-run=server "$@" -- sleep 1 2>&1) && return 1; echo "$out" | grep -q 'violates PodSecurity'; }
"""
ETCD = r"""
# shows FILE MARKER: a saved etcd value - raw, as text, or as a `hexdump -C` - contains MARKER
shows() { grep -qa -- "$2" "$1" 2>/dev/null || sed -n 's/^[0-9a-f]\{8\} .*|\(.*\)|$/\1/p' "$1" 2>/dev/null | tr -d '\n' | grep -q -- "$2"; }
# etcdval KEY: what etcd stores under KEY, with the unprintable bytes dropped
etcdval() {
nx "$CP" "ETCDCTL_API=3 etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key get '$1' --print-value-only" </dev/null 2>/dev/null | tr -dc '[:print:]\n'
}
# enc_setup: encrypt Secrets at rest with aescbc, key "key1" (/etc/kubernetes/enc/enc.yaml)
enc_setup() {
local key; key=$(head -c 32 /dev/urandom | base64)
nx "$CP" 'mkdir -p /etc/kubernetes/enc && cat > /etc/kubernetes/enc/enc.yaml && chmod 600 /etc/kubernetes/enc/enc.yaml' <<EOF || return 1
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- aescbc:
keys:
- name: key1
secret: $key
- identity: {}
EOF
medit kube-apiserver.yaml '.spec.containers[0].command += ["--encryption-provider-config=/etc/kubernetes/enc/enc.yaml"] | .spec.containers[0].volumeMounts += [{"name": "enc", "mountPath": "/etc/kubernetes/enc", "readOnly": true}] | .spec.volumes += [{"name": "enc", "hostPath": {"path": "/etc/kubernetes/enc", "type": "DirectoryOrCreate"}}]' || return 1
wait_proc kube-apiserver '--encryption-provider-config=' && wait_api
}
"""
ISTIO = r"""
# ensure_istio: istiod in istio-system (the minimal profile), installed with the host's istioctl
ensure_istio() {
$K -n istio-system get deploy istiod >/dev/null 2>&1 || nx "$CP" 'istioctl install --set profile=minimal -y >/dev/null 2>&1' </dev/null || return 1
$K -n istio-system rollout status deploy istiod --timeout=300s >/dev/null
}
# web NS NAME [PORTS..]: Deployment NAME (label app=NAME) answering on the ports (default 80), with a Service
web() {
local ns=$1 n=$2; shift 2; local ports="${*:-80}" p cmd="mkdir -p /www && echo $n > /www/index.html" sp="" cp=""
for p in $ports; do cmd="$cmd && httpd -p $p -h /www"; sp="$sp{name: http-$p, port: $p, targetPort: $p}, "; cp="$cp{containerPort: $p}, "; done
$K apply -f - >/dev/null <<EOF
apiVersion: apps/v1
kind: Deployment
metadata: {name: $n, namespace: $ns}
spec:
replicas: 1
selector: {matchLabels: {app: $n}}
template:
metadata: {labels: {app: $n}}
spec:
containers:
- name: $n
image: busybox:1.36
command: ["sh", "-c", "$cmd && exec sleep 2147483647"]
ports: [${cp%, }]
---
apiVersion: v1
kind: Service
metadata: {name: $n, namespace: $ns}
spec:
selector: {app: $n}
ports: [${sp%, }]
EOF
}
# meshed NS: every Pod of NS that is not going away has an istio-proxy and is Ready
meshed() {
$K -n "$1" get pod -o json | python3 -c '
import json, sys
p = [x for x in json.load(sys.stdin)["items"] if "deletionTimestamp" not in x["metadata"]]
ok = bool(p) and all(any(c["name"] == "istio-proxy" for c in x["spec"]["containers"] + x["spec"].get("initContainers", []))
and any(c["type"] == "Ready" and c["status"] == "True" for c in x["status"].get("conditions", [])) for x in p)
sys.exit(0 if ok else 1)'
}
"""
def _levels(r):
lv = r.sample(["restricted", "baseline", "privileged"], 3)
return {"ns": ns_pick(r, "sec"), "la": lv[0], "lb": lv[1], "lc": lv[2]}
QUESTIONS = [
# ================================================== 4.1 Pod Security Standards ====
{
"id": 56, "domain": "MICRO", "title": "Enforce baseline, and find who breaks it",
"params": lambda r: {"ns": ns_pick(r, "legacy")},
"task": """
Namespace `%{ns}` is to enforce the **baseline** Pod Security Standard. It runs five Pods: `web`,
`cache`, `agent`, `tools` and `collector`.
1. Before you change anything, find out which of the running Pods violate `baseline`, and write
their names to `{WS}/q56/violators.txt`, one per line.
2. Label the Namespace: **enforce** `baseline` at version `latest`; **warn** and **audit** at
`restricted`.
3. Enforcement only applies to Pods that are created from now on. Delete the violating Pods
yourself; the others must keep running untouched.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q56"; mkdir -p "$WS/q56"
$K apply -f - >/dev/null <<EOF
apiVersion: v1
kind: Pod
metadata: {name: web, namespace: %{ns}}
spec: {containers: [{name: c, image: "busybox:1.36", command: [sleep, "86400"]}]}
---
apiVersion: v1
kind: Pod
metadata: {name: cache, namespace: %{ns}}
spec: {containers: [{name: c, image: "busybox:1.36", command: [sleep, "86400"], securityContext: {runAsUser: 1000}}]}
---
apiVersion: v1
kind: Pod
metadata: {name: agent, namespace: %{ns}}
spec: {hostNetwork: true, containers: [{name: c, image: "busybox:1.36", command: [sleep, "86400"]}]}
---
apiVersion: v1
kind: Pod
metadata: {name: tools, namespace: %{ns}}
spec: {containers: [{name: c, image: "busybox:1.36", command: [sleep, "86400"], securityContext: {privileged: true}}]}
---
apiVersion: v1
kind: Pod
metadata: {name: collector, namespace: %{ns}}
spec:
volumes: [{name: logs, hostPath: {path: /var/log}}]
containers: [{name: c, image: "busybox:1.36", command: [sleep, "86400"], volumeMounts: [{name: logs, mountPath: /host-logs, readOnly: true}]}]
EOF
$K -n %{ns} wait --for=condition=Ready pod --all --timeout=240s >/dev/null || exit 1
echo "$(j -n %{ns} pod web '{.metadata.uid}') $(j -n %{ns} pod cache '{.metadata.uid}')" > "$PRIV/q56.uids"
""",
"checks": [
C("violators.txt names the three Pods that violate baseline", 3, r"""
[ -s "$WS/q56/violators.txt" ] && diff <(tr -d ' \r' < "$WS/q56/violators.txt" | sed 's#.*/##' | grep . | sort -u) <(printf 'agent\ncollector\ntools\n') >/dev/null"""),
C("the Namespace enforces baseline (latest) and warns and audits at restricted", 3, r"""
l() { j ns %{ns} "{.metadata.labels.pod-security\.kubernetes\.io/$1}"; }
eq "$(l enforce)" baseline && eq "$(l enforce-version)" latest && eq "$(l warn)" restricted && eq "$(l audit)" restricted"""),
C("the violators are gone; web and cache are the same Pods as before", 2, r"""
read -r u1 u2 < "$PRIV/q56.uids"
eq "$(j ns %{ns} '{.metadata.labels.pod-security\.kubernetes\.io/enforce}')" baseline || exit 1
for p in agent tools collector; do [ -z "$(j -n %{ns} pod $p '{.metadata.name}')" ] || exit 1; done
eq "$(j -n %{ns} pod web '{.metadata.uid}')" "$u1" && eq "$(j -n %{ns} pod cache '{.metadata.uid}')" "$u2" """),
C("a privileged Pod is now refused there, an ordinary one admitted", 2, PSA + r"""refuses %{ns} --privileged && admits %{ns}"""),
],
"solution": """
k label --dry-run=server --overwrite ns %{ns} pod-security.kubernetes.io/enforce=baseline
# Warning: existing pods in namespace "%{ns}" violate the new PodSecurity enforce level "baseline:latest"
# Warning: agent: host namespaces / collector: hostPath volumes / tools: privileged
printf 'agent\\ncollector\\ntools\\n' > {WS}/q56/violators.txt
k label ns %{ns} pod-security.kubernetes.io/enforce=baseline pod-security.kubernetes.io/enforce-version=latest \\
pod-security.kubernetes.io/warn=restricted pod-security.kubernetes.io/audit=restricted
k -n %{ns} delete pod agent collector tools
k -n %{ns} run probe --image=busybox:1.36 --privileged --dry-run=server -- sleep 1 # Forbidden: violates PodSecurity
# The server-side dry run of the label is the quickest audit there is: the API server evaluates
# every existing Pod and answers with warnings, and nothing is changed.
# warn and audit at a stricter level than enforce show what the next step would break.
""",
},
{
"id": 57, "domain": "MICRO", "title": "A Pod Security default for the whole cluster", "difficulty": "hard",
"task": """
Namespaces of this cluster that carry no Pod Security labels admit anything. Give the cluster a
default: every Namespace without labels of its own enforces **baseline** — except `kube-system`,
which is exempt.
On `{CP}`:
1. Write an `AdmissionConfiguration` to `/etc/kubernetes/psa/admission.yaml` that configures the
`PodSecurity` plugin with a `PodSecurityConfiguration`: defaults `enforce: baseline`,
`audit: restricted`, `warn: restricted` (versions `latest`), and Namespace `kube-system` exempt.
2. Make the API server use it (`--admission-control-config-file`); the file has to be visible
inside the static Pod.
3. The API server must be back and healthy.
""",
"setup": r"""
nx "$CP" 'rm -rf /etc/kubernetes/psa' </dev/null
""",
"checks": [
C("admission.yaml configures PodSecurity: enforce baseline by default, kube-system exempt", 3, r"""
f=/etc/kubernetes/psa/admission.yaml; y() { nx "$CP" "yq '$1' $f" </dev/null; }
eq "$(y .kind)" AdmissionConfiguration && eq "$(y '.plugins[0].name')" PodSecurity && eq "$(y '.plugins[0].configuration.kind')" PodSecurityConfiguration \
&& eq "$(y '.plugins[0].configuration.defaults.enforce')" baseline && eq "$(y '.plugins[0].configuration.defaults.warn')" restricted \
&& eq "$(y '.plugins[0].configuration.defaults.audit')" restricted && y '.plugins[0].configuration.exemptions.namespaces[]' | grep -qx kube-system"""),
C("the API server runs with --admission-control-config-file and is healthy", 3, API + r"""
runs kube-apiserver '--admission-control-config-file=/etc/kubernetes/psa/admission.yaml' && wait_api 90 && pod_ready kube-system "kube-apiserver-$CP" 90"""),
C("a Namespace without labels refuses a privileged Pod; kube-system does not", 4, API + PSA + r"""
runs kube-apiserver '--admission-control-config-file=' && wait_api 60 || exit 1
$K create ns ksim-psa-check >/dev/null 2>&1; for i in $(seq 1 30); do $K -n ksim-psa-check get sa default >/dev/null 2>&1 && break; sleep 1; done
refuses ksim-psa-check --privileged; a=$?; admits ksim-psa-check; b=$?; admits kube-system --privileged; c=$?
$K delete ns ksim-psa-check --wait=false >/dev/null 2>&1
[ $a = 0 ] && [ $b = 0 ] && [ $c = 0 ]"""),
],
"solution": """
sudo -i
mkdir -p /etc/kubernetes/psa
cat > /etc/kubernetes/psa/admission.yaml <<EOF
apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
- name: PodSecurity
configuration:
apiVersion: pod-security.admission.config.k8s.io/v1
kind: PodSecurityConfiguration
defaults:
enforce: baseline
enforce-version: latest
audit: restricted
audit-version: latest
warn: restricted
warn-version: latest
exemptions:
usernames: []
runtimeClasses: []
namespaces: [kube-system]
EOF
cp /etc/kubernetes/manifests/kube-apiserver.yaml /root/
vi /etc/kubernetes/manifests/kube-apiserver.yaml
# command: - --admission-control-config-file=/etc/kubernetes/psa/admission.yaml
# volumeMounts: - {name: psa, mountPath: /etc/kubernetes/psa, readOnly: true}
# volumes: - {name: psa, hostPath: {path: /etc/kubernetes/psa, type: DirectoryOrCreate}}
watch crictl ps # until kube-apiserver is back
kubectl create ns demo && kubectl -n demo run p --image=busybox:1.36 --privileged --dry-run=server -- sleep 1 # Forbidden
# docs: "Enforce Pod Security Standards by Configuring the Built-in Admission Controller" has this
# file to copy. Labels on a Namespace still override the defaults. An exemption switches the
# checks off for that Namespace altogether - the system Pods need it.
""",
},
{
"id": 58, "domain": "MICRO", "title": "A Deployment that restricted refuses",
"params": lambda r: {"ns": ns_pick(r, "strict"), "dep": name_pick(r, "uploader")},
"task": """
Deployment `%{dep}` in Namespace `%{ns}` has no Pods: the Namespace enforces the **restricted**
Pod Security Standard, and the Deployment's Pod template violates it in several ways.
Fix the Deployment — not the Namespace — so that both replicas run:
- keep the image (`busybox:1.36`), the command and the 2 replicas;
- the container only needs a scratch directory at `/data`: it has no use for the node's
filesystem.
`k -n %{ns} describe rs` (or the Namespace's events) names everything that is refused.
""",
"setup": NS + r"""
mkns %{ns}
$K label ns %{ns} pod-security.kubernetes.io/enforce=restricted pod-security.kubernetes.io/enforce-version=latest --overwrite >/dev/null
$K apply -f - >/dev/null 2>&1 <<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: data, hostPath: {path: /var/lib/uploads, type: DirectoryOrCreate}}
containers:
- name: uploader
image: busybox:1.36
command: ["sh", "-c", "while true; do date > /data/heartbeat; sleep 5; done"]
securityContext: {runAsUser: 0, capabilities: {add: [NET_ADMIN, SYS_TIME]}}
volumeMounts: [{name: data, mountPath: /data}]
EOF
[ -n "$(j -n %{ns} deploy %{dep} '{.metadata.name}')" ]
""",
"checks": [
C("both replicas are available, under the unchanged restricted enforcement, image and command kept", 4, r"""
eq "$(j ns %{ns} '{.metadata.labels.pod-security\.kubernetes\.io/enforce}')" restricted || exit 1
eq "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.containers[0].image}')" busybox:1.36 && eq "$(j -n %{ns} deploy %{dep} '{.spec.replicas}')" 2 \
&& j -n %{ns} deploy %{dep} '{.spec.template.spec.containers[0].command}' | grep -q heartbeat || exit 1
for i in $(seq 1 20); do [ "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" = 2 ] && exit 0; sleep 3; done; exit 1"""),
C("the Pod template meets restricted: non-root, no escalation, all capabilities dropped, seccomp, no hostPath", 4, PY + r"""
py -n %{ns} deploy %{dep} '
t = o["spec"]["template"]["spec"]; ps = t.get("securityContext", {}); c = t["containers"][0]; cs = c.get("securityContext", {})
nonroot = cs.get("runAsNonRoot", ps.get("runAsNonRoot")) is True and cs.get("runAsUser", ps.get("runAsUser")) not in (0,)
sec = (cs.get("seccompProfile") or ps.get("seccompProfile") or {}).get("type") in ("RuntimeDefault", "Localhost")
caps = cs.get("capabilities", {})
nonroot and sec and cs.get("allowPrivilegeEscalation") is False and caps.get("drop") == ["ALL"] and set(caps.get("add", [])) <= {"NET_BIND_SERVICE"} and not any("hostPath" in v for v in t.get("volumes", []))'"""),
C("/data is an emptyDir the container writes to, and it does not run as root", 2, PY + r"""
p=$($K -n %{ns} get pod -l app=%{dep} --field-selector status.phase=Running -o jsonpath='{.items[0].metadata.name}'); [ -n "$p" ] || exit 1
u=$($K -n %{ns} exec "$p" -- id -u 2>/dev/null); [ -n "$u" ] && [ "$u" != 0 ] && $K -n %{ns} exec "$p" -- test -s /data/heartbeat || exit 1
py -n %{ns} pod "$p" '
m = [x["name"] for c in o["spec"]["containers"] for x in c.get("volumeMounts", []) if x["mountPath"].rstrip("/") == "/data"]
len(m) == 1 and any(v["name"] == m[0] and "emptyDir" in v for v in o["spec"]["volumes"])'"""),
],
"solution": """
k -n %{ns} describe rs | grep -A6 Warning # FailedCreate ... violates PodSecurity "restricted:latest":
# allowPrivilegeEscalation != false, unrestricted capabilities, restricted volume types (hostPath),
# runAsNonRoot != true, runAsUser=0, seccompProfile
k -n %{ns} edit deploy %{dep}
# spec.template.spec:
# securityContext: {runAsNonRoot: true, runAsUser: 1000, seccompProfile: {type: RuntimeDefault}}
# volumes: [{name: data, emptyDir: {}}]
# containers[0].securityContext: {allowPrivilegeEscalation: false, capabilities: {drop: [ALL]}}
k -n %{ns} rollout status deploy %{dep}
# The Deployment itself was accepted (with a warning): Pod Security judges Pods, so the failure only
# shows on the ReplicaSet. restricted wants four things on every container - runAsNonRoot,
# allowPrivilegeEscalation: false, capabilities drop ALL, a seccomp profile - and only "safe" volume
# types: no hostPath.
""",
},
{
"id": 59, "domain": "MICRO", "title": "The strictest level each Namespace passes",
"params": _levels,
"task": """
Three Namespaces are to get the **strictest** Pod Security Standard that the workloads running in
them already satisfy: `%{ns}-a`, `%{ns}-b` and `%{ns}-c`.
1. For each of them set the label `pod-security.kubernetes.io/enforce` to `restricted`, `baseline`
or `privileged` — the strictest level under which its Pods would still be admitted as they are.
2. Write the result to `{WS}/q59/levels.txt`, one line per Namespace: `<namespace> <level>`.
Do not change, restart or delete any workload.
""",
"setup": NS + r"""
rm -rf "$WS/q59"; mkdir -p "$WS/q59"; : > "$PRIV/q59"
mk() { # mk NAMESPACE LEVEL: a Deployment that satisfies LEVEL and nothing stricter
mkns $1
case $2 in
restricted) spec='securityContext: {runAsNonRoot: true, runAsUser: 1000, seccompProfile: {type: RuntimeDefault}}
containers:
- {name: app, image: "busybox:1.36", command: [sleep, "86400"], securityContext: {allowPrivilegeEscalation: false, capabilities: {drop: [ALL]}}}' ;;
baseline) spec='containers:
- {name: app, image: "busybox:1.36", command: [sleep, "86400"]}' ;;
privileged) spec='hostPID: true
containers:
- {name: app, image: "busybox:1.36", command: [sleep, "86400"]}' ;;
esac
$K apply -f - >/dev/null <<EOF
apiVersion: apps/v1
kind: Deployment
metadata: {name: app, namespace: $1}
spec:
replicas: 1
selector: {matchLabels: {app: app}}
template:
metadata: {labels: {app: app}}
spec:
$spec
EOF
}
mk %{ns}-a %{la}; mk %{ns}-b %{lb}; mk %{ns}-c %{lc}
for s in a b c; do $K -n %{ns}-$s rollout status deploy app --timeout=240s >/dev/null || exit 1; done
for s in a b c; do echo "%{ns}-$s $($K -n %{ns}-$s get pod -l app=app -o jsonpath='{.items[0].metadata.uid}')" >> "$PRIV/q59"; done
""",
"checks": [
C("%{ns}-a enforces %{la}", 3, r"""eq "$(j ns %{ns}-a '{.metadata.labels.pod-security\.kubernetes\.io/enforce}')" %{la}"""),
C("%{ns}-b enforces %{lb}", 3, r"""eq "$(j ns %{ns}-b '{.metadata.labels.pod-security\.kubernetes\.io/enforce}')" %{lb}"""),
C("%{ns}-c enforces %{lc}", 2, r"""eq "$(j ns %{ns}-c '{.metadata.labels.pod-security\.kubernetes\.io/enforce}')" %{lc}"""),
C("levels.txt says the same, and no Pod was touched", 2, r"""
diff <(tr -s ' \t' ' ' < "$WS/q59/levels.txt" 2>/dev/null | sed 's/ *$//' | grep . | sort) <(printf '%{ns}-a %{la}\n%{ns}-b %{lb}\n%{ns}-c %{lc}\n' | sort) >/dev/null || exit 1
while read -r n uid; do eq "$($K -n $n get pod -l app=app -o jsonpath='{.items[0].metadata.uid}')" "$uid" || exit 1; done < "$PRIV/q59" """),
],
"solution": """
for n in %{ns}-a %{ns}-b %{ns}-c; do for l in restricted baseline; do
echo "== $n $l"; k label --dry-run=server --overwrite ns $n pod-security.kubernetes.io/enforce=$l 2>&1 | grep -i warning
done; done
# no warning at restricted -> restricted
# warning at restricted only -> baseline
# warning at baseline as well -> privileged
k label ns %{ns}-a pod-security.kubernetes.io/enforce=%{la}
k label ns %{ns}-b pod-security.kubernetes.io/enforce=%{lb}
k label ns %{ns}-c pod-security.kubernetes.io/enforce=%{lc}
printf '%{ns}-a %{la}\\n%{ns}-b %{lb}\\n%{ns}-c %{lc}\\n' > {WS}/q59/levels.txt
# The dry run evaluates the Pods that exist now against the level and warns for each violation -
# without it you would be reading Pod specs against the standard's tables by hand.
""",
},
# ===================================================================== 4.2 Secrets ====
{
"id": 60, "domain": "MICRO", "title": "Encrypt Secrets at rest", "difficulty": "hard",
"params": lambda r: {"ns": ns_pick(r, "bank"), "pw": "pw-" + "".join(r.choice("abcdefghjkmnpqrstuvwxyz23456789") for _ in range(14))},
"task": """
This cluster stores Secrets in etcd as plain text. Encrypt them at rest. On `{CP}`:
1. Write an `EncryptionConfiguration` to `/etc/kubernetes/enc/enc.yaml` for the resource
`secrets`: provider `aescbc` with one key named `key1` (32 random bytes, base64-encoded),
followed by the `identity` provider.
2. Make the API server use it (`--encryption-provider-config`); the file has to be visible inside
the static Pod.
3. Existing Secrets stay as they are until they are written again: rewrite every Secret in
Namespace `%{ns}`.
4. Read Secret `db-credentials` of `%{ns}` directly from etcd with `etcdctl` and save what etcd
returns to `{WS}/q60/etcd.txt` (as text or as a hex dump).
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q60"; mkdir -p "$WS/q60"
nx "$CP" 'rm -rf /etc/kubernetes/enc' </dev/null
$K -n %{ns} create secret generic db-credentials --from-literal=user=ledger --from-literal=password=%{pw} >/dev/null
$K -n %{ns} create secret generic api-token --from-literal=token=t-%{pw} >/dev/null
""",
"checks": [
C("enc.yaml encrypts secrets with aescbc key1 first, identity after it", 2, r"""
f=/etc/kubernetes/enc/enc.yaml; y() { nx "$CP" "yq '$1' $f" </dev/null; }
eq "$(y .kind)" EncryptionConfiguration && y '.resources[0].resources[]' | grep -qx secrets && eq "$(y '.resources[0].providers[0].aescbc.keys[0].name')" key1 \
&& eq "$(y '.resources[0].providers[0].aescbc.keys[0].secret' | base64 -d 2>/dev/null | wc -c)" 32 && [ "$(y '.resources[0].providers[1] | has("identity")')" = true ]"""),
C("the API server runs with --encryption-provider-config and is healthy", 3, API + r"""
runs kube-apiserver '--encryption-provider-config=/etc/kubernetes/enc/enc.yaml' && wait_api 90 && pod_ready kube-system "kube-apiserver-$CP" 90"""),
C("in etcd, both Secrets of %{ns} are encrypted with key1 - and the API still returns the password", 4, API + ETCD + r"""
wait_api 30 || exit 1
etcdval /registry/secrets/%{ns}/db-credentials | grep -q '^k8s:enc:aescbc:v1:key1:' && etcdval /registry/secrets/%{ns}/api-token | grep -q '^k8s:enc:aescbc:v1:key1:' \
&& ! etcdval /registry/secrets/%{ns}/db-credentials | grep -q '%{pw}' && eq "$(j -n %{ns} secret db-credentials '{.data.password}' | base64 -d)" %{pw}"""),
C("etcd.txt shows the encrypted value", 1, ETCD + r"""shows "$WS/q60/etcd.txt" 'k8s:enc:aescbc:v1:key1'"""),
],
"solution": """
sudo -i
mkdir -p /etc/kubernetes/enc
cat > /etc/kubernetes/enc/enc.yaml <<EOF
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources: [secrets]
providers:
- aescbc:
keys:
- name: key1
secret: $(head -c 32 /dev/urandom | base64)
- identity: {}
EOF
cp /etc/kubernetes/manifests/kube-apiserver.yaml /root/
vi /etc/kubernetes/manifests/kube-apiserver.yaml
# command: - --encryption-provider-config=/etc/kubernetes/enc/enc.yaml
# volumeMounts: - {name: enc, mountPath: /etc/kubernetes/enc, readOnly: true}
# volumes: - {name: enc, hostPath: {path: /etc/kubernetes/enc, type: DirectoryOrCreate}}
watch crictl ps # until kube-apiserver is back
kubectl -n %{ns} get secrets -o json | kubectl replace -f -
ETCDCTL_API=3 etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt \\
--cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key \\
get /registry/secrets/%{ns}/db-credentials | hexdump -C | tee {WS}/q60/etcd.txt | head -4 # k8s:enc:aescbc:v1:key1:...
# docs: "Encrypting Confidential Data at Rest". The first provider encrypts, all of them can
# decrypt: `identity` last keeps the old plain-text Secrets readable until they are rewritten.
# With identity FIRST nothing would be encrypted at all.
""",
},
{
"id": 61, "domain": "MICRO", "title": "Read a Secret straight from etcd",
"params": lambda r: {"ns": ns_pick(r, "treasury"), "name": name_pick(r, "signing-key"),
"tok": "tok-" + "".join(r.choice("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") for _ in range(20))},
"task": """
The team believes that Secrets are safe because RBAC lets almost nobody read them. Show what
access to etcd means. On `{CP}`, **without asking the Kubernetes API** for it:
1. Read Secret `%{name}` of Namespace `%{ns}` directly from etcd with `etcdctl`. etcd's client
certificates are in `/etc/kubernetes/pki/etcd/`.
2. Write the value of the Secret's key `token`, as plain text, to `{WS}/q61/token.txt`.
3. Write the etcd key under which the Secret is stored (it starts with `/registry/`) to
`{WS}/q61/key.txt`.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q61"; mkdir -p "$WS/q61"
$K -n %{ns} create secret generic %{name} --from-literal=token=%{tok} --from-literal=owner=treasury >/dev/null
""",
"checks": [
C("token.txt holds the Secret's token", 6, r"""eq "$(tr -d ' \n\r' < "$WS/q61/token.txt" 2>/dev/null)" %{tok}"""),
C("key.txt holds the etcd key of the Secret", 4, r"""eq "$(tr -d ' \n\r' < "$WS/q61/key.txt" 2>/dev/null)" /registry/secrets/%{ns}/%{name}"""),
],
"solution": """
sudo -i
export ETCDCTL_API=3
E="etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key"
$E get /registry/secrets/%{ns} --prefix --keys-only # /registry/secrets/%{ns}/%{name} -> {WS}/q61/key.txt
$E get /registry/secrets/%{ns}/%{name} | hexdump -C | less # the value is in there, in the clear: token ... %{tok}
$E get /registry/secrets/%{ns}/%{name} --print-value-only | strings | grep tok-
# Objects are stored as protobuf under /registry/<resource>/<namespace>/<name>; a Secret's data is
# in it as raw bytes - not even base64. Whoever reads etcd, or a backup of it, reads every Secret,
# whatever RBAC says: hence client certificates on port 2379, and encryption at rest.
""",
},
{
"id": 62, "domain": "MICRO", "title": "From environment variables to files",
"params": lambda r: {"ns": ns_pick(r, "crm"), "dep": name_pick(r, "sync"),
"pw": "s3c-" + "".join(r.choice("abcdefghjkmnpqrstuvwxyz23456789") for _ in range(12))},
"task": """
Deployment `%{dep}` in Namespace `%{ns}` gets the credentials of Secret `api-creds` as the
environment variables `API_USER` and `API_PASSWORD` — where every child process, crash report and
`kubectl describe` of a careless tool can see them.
Change the Deployment so that it gets them as files instead:
- Secret `api-creds` mounted read-only at `/etc/creds`, the files with mode `0400`;
- no trace of the two variables in the container's environment.
Leave the image and the command as they are. The Deployment must be available.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} create secret generic api-creds --from-literal=user=svc-sync --from-literal=password=%{pw} >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{dep}, namespace: %{ns}}
spec:
replicas: 1
selector: {matchLabels: {app: %{dep}}}
template:
metadata: {labels: {app: %{dep}}}
spec:
containers:
- name: sync
image: busybox:1.36
command: ["sh", "-c", "while true; do echo syncing; sleep 30; done"]
env:
- {name: SYNC_INTERVAL, value: "30"}
- {name: API_USER, valueFrom: {secretKeyRef: {name: api-creds, key: user}}}
- {name: API_PASSWORD, valueFrom: {secretKeyRef: {name: api-creds, key: password}}}
EOF
$K -n %{ns} rollout status deploy %{dep} --timeout=180s >/dev/null
""",
"checks": [
C("the Pod template no longer takes the Secret into the environment", 3, PY + r"""
py -n %{ns} deploy %{dep} '
c = o["spec"]["template"]["spec"]["containers"][0]
not any(e["name"] in ("API_USER", "API_PASSWORD") or "secretKeyRef" in e.get("valueFrom", {}) for e in c.get("env", [])) and not any("secretRef" in e for e in c.get("envFrom", [])) and any(e["name"] == "SYNC_INTERVAL" for e in c.get("env", []))'"""),
C("Secret api-creds is mounted read-only at /etc/creds with mode 0400", 3, PY + r"""
py -n %{ns} deploy %{dep} '
t = o["spec"]["template"]["spec"]; c = t["containers"][0]
v = {x["name"]: x["secret"] for x in t.get("volumes", []) if "secret" in x}
m = [x for x in c.get("volumeMounts", []) if x["mountPath"].rstrip("/") == "/etc/creds"]
bool(m) and m[0].get("readOnly") is True and v.get(m[0]["name"], {}).get("secretName") == "api-creds" and v[m[0]["name"]].get("defaultMode") == 0o400'"""),
C("in the running Pod the password is a file of mode 400 and not in the environment", 4, r"""
for i in $(seq 1 20); do [ "$(j -n %{ns} deploy %{dep} '{.status.updatedReplicas}')" = 1 ] && [ "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" = 1 ] && break; sleep 3; done
p=$($K -n %{ns} get pod -l app=%{dep} --field-selector status.phase=Running --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1:].metadata.name}'); [ -n "$p" ] || exit 1
eq "$($K -n %{ns} exec "$p" -- cat /etc/creds/password 2>/dev/null)" %{pw} && eq "$($K -n %{ns} exec "$p" -- stat -L -c %a /etc/creds/password 2>/dev/null)" 400 \
&& ! $K -n %{ns} exec "$p" -- env 2>/dev/null | grep -q -e '%{pw}' -e '^API_'"""),
],
"solution": """
k -n %{ns} edit deploy %{dep}
# containers[0]:
# env: # delete API_USER and API_PASSWORD, keep SYNC_INTERVAL
# volumeMounts:
# - {name: creds, mountPath: /etc/creds, readOnly: true}
# volumes:
# - name: creds
# secret: {secretName: api-creds, defaultMode: 0400}
k -n %{ns} rollout status deploy %{dep}
k -n %{ns} exec deploy/%{dep} -- sh -c 'ls -lL /etc/creds; env | grep API'
# Each key of the Secret becomes a file. A mounted Secret is updated in place when the Secret
# changes; an environment variable never is. defaultMode is octal in YAML (0400) and the same
# number in decimal (256) in JSON - `kubectl get -o json` shows 256.
""",
},
{
"id": 63, "domain": "MICRO", "title": "Secrets in a ConfigMap",
"params": lambda r: {"ns": ns_pick(r, "shop"), "dep": name_pick(r, "checkout"),
"pw": "db-" + "".join(r.choice("abcdefghjkmnpqrstuvwxyz23456789") for _ in range(12)),
"tok": "at-" + "".join(r.choice("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") for _ in range(16))},
"task": """
ConfigMap `app-config` in Namespace `%{ns}` holds four settings of Deployment `%{dep}`. Two of
them are secrets and have no business in a ConfigMap: `DB_PASSWORD` and `API_TOKEN`.
1. Create Secret `app-secrets` in `%{ns}` with these two keys and their present values.
2. Remove the two keys from the ConfigMap. `LOG_LEVEL` and `DB_HOST` stay.
3. The Deployment's container must still find all four in its environment, under the same names:
two from the ConfigMap, two from the Secret.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} create configmap app-config --from-literal=LOG_LEVEL=info --from-literal=DB_HOST=db.%{ns}.svc --from-literal=DB_PASSWORD=%{pw} --from-literal=API_TOKEN=%{tok} >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{dep}, namespace: %{ns}}
spec:
replicas: 1
selector: {matchLabels: {app: %{dep}}}
template:
metadata: {labels: {app: %{dep}}}
spec:
containers:
- name: app
image: busybox:1.36
command: [sleep, "86400"]
envFrom: [{configMapRef: {name: app-config}}]
EOF
$K -n %{ns} rollout status deploy %{dep} --timeout=180s >/dev/null
""",
"checks": [
C("Secret app-secrets holds DB_PASSWORD and API_TOKEN with their values", 3, r"""
eq "$(j -n %{ns} secret app-secrets '{.data.DB_PASSWORD}' | base64 -d)" %{pw} && eq "$(j -n %{ns} secret app-secrets '{.data.API_TOKEN}' | base64 -d)" %{tok}"""),
C("the ConfigMap no longer has them, and keeps LOG_LEVEL and DB_HOST", 3, PY + r"""
py -n %{ns} cm app-config 'sorted(o.get("data", {})) == ["DB_HOST", "LOG_LEVEL"]'"""),
C("the running container still has all four variables, the two secrets from the Secret", 4, PY + r"""
py -n %{ns} deploy %{dep} '
c = o["spec"]["template"]["spec"]["containers"][0]
any(e.get("secretRef", {}).get("name") == "app-secrets" for e in c.get("envFrom", [])) or sum(1 for e in c.get("env", []) if e.get("valueFrom", {}).get("secretKeyRef", {}).get("name") == "app-secrets") >= 2' || exit 1
for i in $(seq 1 20); do [ "$(j -n %{ns} deploy %{dep} '{.status.updatedReplicas}')" = 1 ] && [ "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" = 1 ] && break; sleep 3; done
p=$($K -n %{ns} get pod -l app=%{dep} --field-selector status.phase=Running --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1:].metadata.name}'); [ -n "$p" ] || exit 1
e=$($K -n %{ns} exec "$p" -- env 2>/dev/null)
echo "$e" | grep -qx 'DB_PASSWORD=%{pw}' && echo "$e" | grep -qx 'API_TOKEN=%{tok}' && echo "$e" | grep -qx 'LOG_LEVEL=info' && echo "$e" | grep -q '^DB_HOST=db\.'"""),
],
"solution": """
k -n %{ns} get cm app-config -o yaml
k -n %{ns} create secret generic app-secrets \\
--from-literal=DB_PASSWORD="$(k -n %{ns} get cm app-config -o jsonpath='{.data.DB_PASSWORD}')" \\
--from-literal=API_TOKEN="$(k -n %{ns} get cm app-config -o jsonpath='{.data.API_TOKEN}')"
k -n %{ns} patch cm app-config --type=json -p '[{"op":"remove","path":"/data/DB_PASSWORD"},{"op":"remove","path":"/data/API_TOKEN"}]'
k -n %{ns} edit deploy %{dep}
# envFrom:
# - configMapRef: {name: app-config}
# - secretRef: {name: app-secrets}
k -n %{ns} rollout status deploy %{dep}
k -n %{ns} exec deploy/%{dep} -- env | grep -E 'DB_|API_|LOG_'
# A ConfigMap is readable by everyone with `view`; a Secret is not, can be encrypted at rest and is
# only sent to nodes that run a Pod using it. envFrom takes every key of the object as a variable.
# Changing a ConfigMap does not restart Pods: without the rollout the old values would stay in
# the running container's environment.
""",
},
{
"id": 64, "domain": "MICRO", "title": "Rotate the encryption key", "difficulty": "hard",
"params": lambda r: {"ns": ns_pick(r, "vault"), "pw": "vp-" + "".join(r.choice("abcdefghjkmnpqrstuvwxyz23456789") for _ in range(14))},
"task": """
This cluster encrypts Secrets at rest with `aescbc` and a key named `key1`
(`/etc/kubernetes/enc/enc.yaml` on `{CP}`). That key may have leaked. Replace it:
1. Add a new key named `key2` (32 random bytes, base64) to the `aescbc` provider so that **it** is
used for writing, while `key1` can still decrypt. Restart the API server — it reads the file
only when it starts.
2. Rewrite every Secret of the cluster, so that all of them are encrypted with `key2`.
3. Remove `key1` from the configuration and restart the API server again.
4. Save what etcd stores for Secret `vault-pass` of Namespace `%{ns}` to `{WS}/q64/etcd.txt`.
All Secrets must stay readable through the API.
""",
"setup": NS + API + ETCD + r"""
mkns %{ns}; rm -rf "$WS/q64"; mkdir -p "$WS/q64"
enc_setup || { echo "could not switch on encryption at rest" >&2; exit 1; }
$K -n %{ns} create secret generic vault-pass --from-literal=password=%{pw} >/dev/null
$K -n kube-system create secret generic ksim-canary --from-literal=k=v >/dev/null
etcdval /registry/secrets/%{ns}/vault-pass | grep -q '^k8s:enc:aescbc:v1:key1:'
""",
"checks": [
C("enc.yaml: aescbc has key2 and no key1", 3, r"""
f=/etc/kubernetes/enc/enc.yaml; y() { nx "$CP" "yq '$1' $f" </dev/null; }
eq "$(y '[.resources[0].providers[] | select(has("aescbc")) | .aescbc.keys[].name] | join(",")')" key2 \
&& eq "$(y '.resources[0].providers[] | select(has("aescbc")) | .aescbc.keys[0].secret' | base64 -d 2>/dev/null | wc -c)" 32"""),
C("Secret vault-pass is stored encrypted with key2 and still readable through the API", 4, API + ETCD + r"""
wait_api 90 || exit 1
etcdval /registry/secrets/%{ns}/vault-pass | grep -q '^k8s:enc:aescbc:v1:key2:' && eq "$(j -n %{ns} secret vault-pass '{.data.password}' | base64 -d)" %{pw}"""),
C("so are the Secrets of other Namespaces", 2, API + ETCD + r"""
wait_api 30 || exit 1
etcdval /registry/secrets/kube-system/ksim-canary | grep -q '^k8s:enc:aescbc:v1:key2:' && eq "$(j -n kube-system secret ksim-canary '{.data.k}' | base64 -d)" v \
&& $K get secrets -A >/dev/null 2>&1"""),
C("etcd.txt shows key2", 1, ETCD + r"""shows "$WS/q64/etcd.txt" 'k8s:enc:aescbc:v1:key2'"""),
],
"solution": """
sudo -i
vi /etc/kubernetes/enc/enc.yaml # key2 FIRST, key1 second:
# - aescbc:
# keys:
# - {name: key2, secret: <head -c 32 /dev/urandom | base64>}
# - {name: key1, secret: <as it was>}
# restart the API server: move its manifest out of the directory and back
mv /etc/kubernetes/manifests/kube-apiserver.yaml /root/ && sleep 20 && mv /root/kube-apiserver.yaml /etc/kubernetes/manifests/
watch crictl ps # until kube-apiserver is back
kubectl get secrets -A -o json | kubectl replace -f - # every Secret is written again, with key2
vi /etc/kubernetes/enc/enc.yaml # now delete key1
mv /etc/kubernetes/manifests/kube-apiserver.yaml /root/ && sleep 20 && mv /root/kube-apiserver.yaml /etc/kubernetes/manifests/
ETCDCTL_API=3 etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt \\
--key=/etc/kubernetes/pki/etcd/server.key get /registry/secrets/%{ns}/vault-pass | strings | tee {WS}/q64/etcd.txt # k8s:enc:aescbc:v1:key2:
kubectl get secrets -A >/dev/null && echo all readable
# The order of the three steps is the whole task: new key first (writes use the first key, reads
# try them all), rewrite, and only then drop the old key. Removing key1 before the rewrite leaves
# every Secret unreadable - and `kubectl get secrets -A` failing for the whole cluster.
""",
},
# =================================================================== 4.3 Isolation ====
{
"id": 65, "domain": "MICRO", "title": "Run a workload in gVisor", "workers": 1,
"params": lambda r: {"ns": ns_pick(r, "untrusted"), "dep": name_pick(r, "converter")},
"task": """
Deployment `%{dep}` in Namespace `%{ns}` processes files uploaded by strangers. It should not
share the node's kernel. The nodes of this cluster have gVisor installed: containerd knows a
runtime handler named `runsc`.
1. Create a RuntimeClass named `gvisor` for that handler.
2. Make the Pods of `%{dep}` run with it.
3. Save the output of `dmesg` from inside one of the new Pods to `{WS}/q65/dmesg.txt`.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q65"; mkdir -p "$WS/q65"
$K delete runtimeclass gvisor --ignore-not-found >/dev/null 2>&1
$K -n %{ns} create deploy %{dep} --image=busybox:1.36 -- sleep 86400 >/dev/null
$K -n %{ns} rollout status deploy %{dep} --timeout=180s >/dev/null
""",
"checks": [
C("RuntimeClass gvisor uses the handler runsc", 3, r"""eq "$(j runtimeclass gvisor '{.handler}')" runsc"""),
C("the Deployment's Pods ask for it, and the Deployment is available", 3, r"""
eq "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.runtimeClassName}')" gvisor || exit 1
for i in $(seq 1 30); do [ "$(j -n %{ns} deploy %{dep} '{.status.updatedReplicas}')" = 1 ] && [ "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" = 1 ] && exit 0; sleep 3; done; exit 1"""),
C("the running Pod's kernel is gVisor's", 3, r"""
p=$($K -n %{ns} get pod -l app=%{dep} --field-selector status.phase=Running --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1:].metadata.name}'); [ -n "$p" ] || exit 1
eq "$(j -n %{ns} pod "$p" '{.spec.runtimeClassName}')" gvisor && $K -n %{ns} exec "$p" -- dmesg 2>/dev/null | grep -qi 'gvisor'"""),
C("dmesg.txt shows it", 1, r"""grep -qi 'gvisor' "$WS/q65/dmesg.txt" 2>/dev/null"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata: {name: gvisor}
handler: runsc
EOF
k -n %{ns} patch deploy %{dep} -p '{"spec":{"template":{"spec":{"runtimeClassName":"gvisor"}}}}'
k -n %{ns} rollout status deploy %{dep}
k -n %{ns} exec deploy/%{dep} -- dmesg | tee {WS}/q65/dmesg.txt # [ 0.000000] Starting gVisor...
# RuntimeClass is cluster-scoped and `handler` sits at the top level, not under spec. The handler
# name is whatever the node's containerd configuration calls the runtime:
# sudo grep -A3 'runtimes.runsc' /etc/containerd/config.toml
# In the sandbox, system calls are answered by gVisor's own kernel in user space - `dmesg` prints
# its boot messages instead of the node's.
""",
},
{
"id": 66, "domain": "MICRO", "title": "Fence a tenant in",
"params": lambda r: {"t": r.choice(["acme", "globex", "initech", "umbrella", "hooli"])},
"task": """
Tenant `%{t}` has moved into Namespace `tenant-%{t}`. Give the Namespace its fences:
1. ResourceQuota `tenant-quota`: at most 5 Pods, `requests.cpu` 1, `requests.memory` 1Gi,
`limits.cpu` 2, `limits.memory` 2Gi.
2. LimitRange `tenant-defaults`: containers that say nothing get a request of 100m CPU / 64Mi
memory and a limit of 200m CPU / 128Mi memory.
3. NetworkPolicy `tenant-isolation`: every Pod of the Namespace accepts connections only from
Pods of the same Namespace.
4. RoleBinding `tenant-admins`: group `%{t}-admins` gets the built-in ClusterRole `admin` — in
this Namespace only.
Pods `api` and `client` already run in `tenant-%{t}`; Pod `stranger` runs in Namespace `shared`.
""",
"setup": PODS + r"""
mkns tenant-%{t}; mkns shared
srv tenant-%{t} api; cli tenant-%{t} client; cli shared stranger
ready tenant-%{t} && ready shared
""",
"checks": [
C("ResourceQuota tenant-quota has the five limits", 2, PY + r"""
py -n tenant-%{t} resourcequota tenant-quota '
h = o["spec"]["hard"]
h.get("pods") == "5" and h.get("requests.cpu") == "1" and h.get("requests.memory") == "1Gi" and h.get("limits.cpu") == "2" and h.get("limits.memory") == "2Gi"'"""),
C("a container that says nothing gets the LimitRange's requests and limits", 2, r"""
[ -n "$(j -n tenant-%{t} limitrange tenant-defaults '{.metadata.name}')" ] || exit 1
r=$($K -n tenant-%{t} run ksim-lr-probe --image=busybox:1.36 --dry-run=server -o jsonpath='{.spec.containers[0].resources}' -- sleep 1 2>/dev/null)
echo "$r" | python3 -c '
import json, sys
r = json.load(sys.stdin)
sys.exit(0 if r.get("requests") == {"cpu": "100m", "memory": "64Mi"} and r.get("limits") == {"cpu": "200m", "memory": "128Mi"} else 1)'"""),
C("client reaches api; stranger in another Namespace does not", 3, NET + r"""
has -n tenant-%{t} netpol tenant-isolation && okr tenant-%{t} client http://api && blocked shared stranger http://api.tenant-%{t}"""),
C("group %{t}-admins is admin in tenant-%{t} and nowhere else", 3, r"""
A="--as=someone --as-group=%{t}-admins"
eq "$(j -n tenant-%{t} rolebinding tenant-admins '{.roleRef.kind}/{.roleRef.name}')" ClusterRole/admin || exit 1
[ "$($K auth can-i create deployments -n tenant-%{t} $A 2>/dev/null)" = yes ] && [ "$($K auth can-i get secrets -n tenant-%{t} $A 2>/dev/null)" = yes ] \
&& [ "$($K auth can-i list pods -n shared $A 2>/dev/null | head -1)" = no ] && [ "$($K auth can-i list nodes $A 2>/dev/null | head -1)" = no ]"""),
],
"solution": """
k -n tenant-%{t} create quota tenant-quota --hard=pods=5,requests.cpu=1,requests.memory=1Gi,limits.cpu=2,limits.memory=2Gi
cat <<EOF | k apply -f -
apiVersion: v1
kind: LimitRange
metadata: {name: tenant-defaults, namespace: tenant-%{t}}
spec:
limits:
- type: Container
defaultRequest: {cpu: 100m, memory: 64Mi}
default: {cpu: 200m, memory: 128Mi}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: tenant-isolation, namespace: tenant-%{t}}
spec:
podSelector: {}
policyTypes: [Ingress]
ingress:
- from: [{podSelector: {}}]
EOF
k -n tenant-%{t} create rolebinding tenant-admins --clusterrole=admin --group=%{t}-admins
# The four fences of a tenant: what it may use (quota), what a careless Pod gets (LimitRange - and
# without it a quota on cpu or memory rejects every Pod that sets no requests), who may talk to it
# (NetworkPolicy), and who may change it (RBAC, bound in the Namespace only).
# In a LimitRange `default` is the limit and `defaultRequest` the request.
""",
},
{
"id": 67, "domain": "MICRO", "title": "Workloads that reach into the node",
"params": lambda r: {"ns": ns_pick(r, "platform")},
"task": """
Namespace `%{ns}` runs six Deployments. Some of them reach out of their container into the node:
a Deployment counts if its Pod template shares one of the node's namespaces (`hostPID`, `hostIPC`,
`hostNetwork`), runs a privileged container, or mounts a `hostPath` volume.
1. Write the names of those Deployments to `{WS}/q67/breakers.txt`, one per line.
2. `log-shipper` has a reason: it reads the node's logs. Give it the least it needs — its hostPath
volume limited to `/var/log` and mounted **read-only**. It must stay available.
3. Scale every other Deployment on your list to 0 replicas until its owners have fixed it. Leave
the rest of the Namespace alone.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q67"; mkdir -p "$WS/q67"
dep() { # dep NAME 'pod spec lines' 'container lines'
$K apply -f - >/dev/null <<EOF
apiVersion: apps/v1
kind: Deployment
metadata: {name: $1, namespace: %{ns}}
spec:
replicas: 1
selector: {matchLabels: {app: $1}}
template:
metadata: {labels: {app: $1}}
spec:
$2
containers:
- name: main
image: busybox:1.36
command: [sleep, "86400"]
$3
EOF
}
dep frontend 'terminationGracePeriodSeconds: 5' 'resources: {}'
dep worker 'volumes: [{name: scratch, emptyDir: {}}]' 'volumeMounts: [{name: scratch, mountPath: /scratch}]'
dep metrics-agent 'hostPID: true' 'resources: {}'
dep log-shipper 'volumes: [{name: host, hostPath: {path: /}}]' 'volumeMounts: [{name: host, mountPath: /host}]'
dep ci-runner 'volumes: [{name: sock, hostPath: {path: /run/containerd/containerd.sock}}]' 'volumeMounts: [{name: sock, mountPath: /run/containerd/containerd.sock}]'
dep net-tool 'terminationGracePeriodSeconds: 5' 'securityContext: {privileged: true}'
for d in frontend worker metrics-agent log-shipper ci-runner net-tool; do $K -n %{ns} rollout status deploy $d --timeout=240s >/dev/null || exit 1; done
""",
"checks": [
C("breakers.txt names the four Deployments", 4, r"""
[ -s "$WS/q67/breakers.txt" ] && diff <(tr -d ' \r' < "$WS/q67/breakers.txt" | sed 's#.*/##' | grep . | sort -u) <(printf 'ci-runner\nlog-shipper\nmetrics-agent\nnet-tool\n') >/dev/null"""),
C("log-shipper mounts only /var/log of the node, read-only, and is available", 3, PY + r"""
py -n %{ns} deploy log-shipper '
t = o["spec"]["template"]["spec"]
hp = {v["name"]: v["hostPath"]["path"].rstrip("/") for v in t.get("volumes", []) if "hostPath" in v}
m = [x for c in t["containers"] for x in c.get("volumeMounts", []) if x["name"] in hp]
list(hp.values()) == ["/var/log"] and len(m) == 1 and m[0].get("readOnly") is True and o.get("status", {}).get("availableReplicas") == 1 and o["status"].get("updatedReplicas") == 1'"""),
C("the three others are scaled to 0; frontend and worker still run", 3, r"""
for d in metrics-agent ci-runner net-tool; do eq "$(j -n %{ns} deploy $d '{.spec.replicas}')" 0 || exit 1; done
eq "$(j -n %{ns} deploy frontend '{.status.availableReplicas}')" 1 && eq "$(j -n %{ns} deploy worker '{.status.availableReplicas}')" 1"""),
],
"solution": """
k -n %{ns} get deploy -o json | jq -r '.items[] | select(.spec.template.spec as $s |
$s.hostPID or $s.hostIPC or $s.hostNetwork or any($s.volumes[]?; .hostPath) or any($s.containers[]; .securityContext.privileged))
| .metadata.name' | tee {WS}/q67/breakers.txt
# ci-runner (the runtime socket), log-shipper (hostPath /), metrics-agent (hostPID), net-tool (privileged)
k -n %{ns} edit deploy log-shipper
# volumes: - {name: host, hostPath: {path: /var/log}}
# volumeMounts: - {name: host, mountPath: /host, readOnly: true}
k -n %{ns} scale deploy metrics-agent ci-runner net-tool --replicas=0
# Each of these is a way to the node: hostPID shows (and lets root signal) every process, a
# hostPath of / is the node's whole disk, the containerd socket starts containers with any
# privilege, and a privileged container has every capability and every device.
# worker's emptyDir is not a hostPath: it lives and dies with the Pod.
""",
},
# ======================================================= 4.4 Pod-to-Pod encryption ====
{
"id": 68, "domain": "MICRO", "title": "Mutual TLS for a Namespace (Istio)", "workers": 1,
"params": lambda r: {"ns": ns_pick(r, "orders")},
"task": """
Istio is installed in this cluster (`istiod` in Namespace `istio-system`), but nothing uses it yet:
Deployments `orders` and `payments` in Namespace `%{ns}` talk plain HTTP, and so can anyone else.
1. Switch on automatic sidecar injection for `%{ns}` and get every Pod of the Namespace a
sidecar.
2. Require mutual TLS for every workload of `%{ns}`: PeerAuthentication `default` in `%{ns}`,
mode `STRICT`.
Afterwards `orders` must still reach `http://payments` — the sidecars encrypt that for them —
while a plain-text request from outside the mesh (Pod `legacy-client` in Namespace
`%{ns}-legacy`: `wget -qO- http://payments.%{ns}`) is refused.
""",
"setup": PODS + ISTIO + r"""
ensure_istio || { echo "Istio could not be installed" >&2; exit 1; }
mkns %{ns}; mkns %{ns}-legacy
web %{ns} orders; web %{ns} payments; cli %{ns}-legacy legacy-client
for d in orders payments; do $K -n %{ns} rollout status deploy $d --timeout=240s >/dev/null || exit 1; done
ready %{ns}-legacy
""",
"checks": [
C("Namespace %{ns} has sidecar injection switched on", 2, r"""
eq "$(j ns %{ns} '{.metadata.labels.istio-injection}')" enabled || [ -n "$(j ns %{ns} '{.metadata.labels.istio\.io/rev}')" ]"""),
C("every Pod of the Namespace has an istio-proxy and is Ready", 3, ISTIO + r"""
for i in $(seq 1 20); do meshed %{ns} && exit 0; sleep 3; done; exit 1"""),
C("PeerAuthentication default requires STRICT mutual TLS for the whole Namespace", 2, PY + r"""
py -n %{ns} peerauthentication default 'o["spec"].get("mtls", {}).get("mode") == "STRICT" and not o["spec"].get("selector")'"""),
C("orders still reaches payments; plain text from outside the mesh is refused", 3, NET + ISTIO + r"""
meshed %{ns} && has -n %{ns} peerauthentication default && okr %{ns} deploy/orders http://payments && blocked %{ns}-legacy legacy-client http://payments.%{ns}"""),
],
"solution": """
k label ns %{ns} istio-injection=enabled
k -n %{ns} rollout restart deploy # injection happens when a Pod is created
k -n %{ns} get pod # 2/2: the application and istio-proxy
cat <<EOF | k apply -f -
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata: {name: default, namespace: %{ns}}
spec:
mtls: {mode: STRICT}
EOF
k -n %{ns} exec deploy/orders -- wget -T 3 -qO- http://payments # answers
k -n %{ns}-legacy exec legacy-client -- wget -T 3 -qO- http://payments.%{ns} # connection reset by peer
# Without a selector, a PeerAuthentication named `default` covers its whole Namespace (in
# istio-system: the whole mesh). The default mode is PERMISSIVE - mutual TLS and plain text both
# accepted - which is how a mesh is migrated; STRICT refuses whatever does not come through a
# sidecar with a certificate of the mesh. docs: istio.io -> Tasks -> Security -> Mutual TLS Migration.
""",
},
{
"id": 69, "domain": "MICRO", "title": "Strict for the mesh, one port excepted", "workers": 1, "difficulty": "hard",
"params": lambda r: {"ns": ns_pick(r, "fleet")},
"task": """
Istio runs in this cluster and Namespace `%{ns}` is in the mesh (its Pods have sidecars), but
mutual TLS is only optional (`PERMISSIVE`, the default).
1. Require mutual TLS for the **whole mesh**: PeerAuthentication `default` in the mesh's root
Namespace `istio-system`, mode `STRICT`.
2. One exception: the workload `legacy-metrics` (label `app=legacy-metrics`) in `%{ns}` is scraped
on port **9090** by a system outside the mesh. Create PeerAuthentication `legacy-metrics` in
`%{ns}` that selects that workload: `STRICT` in general, `PERMISSIVE` for port 9090 only.
Test from outside the mesh with Pod `scraper` in Namespace `%{ns}-ext`:
`wget -qO- http://legacy-metrics.%{ns}:9090` must answer; port 80 of the same workload, and
`http://api.%{ns}`, must not.
""",
"setup": PODS + ISTIO + r"""
ensure_istio || { echo "Istio could not be installed" >&2; exit 1; }
mkns %{ns}; mkns %{ns}-ext
$K label ns %{ns} istio-injection=enabled --overwrite >/dev/null
web %{ns} api; web %{ns} legacy-metrics 80 9090; cli %{ns}-ext scraper
for d in api legacy-metrics; do $K -n %{ns} rollout status deploy $d --timeout=240s >/dev/null || exit 1; done
ready %{ns}-ext
for i in $(seq 1 20); do meshed %{ns} && exit 0; sleep 3; done
echo "the Pods of %{ns} did not get a sidecar" >&2; exit 1
""",
"checks": [
C("PeerAuthentication default in istio-system makes the mesh STRICT", 3, PY + r"""
py -n istio-system peerauthentication default 'o["spec"].get("mtls", {}).get("mode") == "STRICT" and not o["spec"].get("selector")'"""),
C("PeerAuthentication legacy-metrics: selects the workload, STRICT, port 9090 PERMISSIVE", 3, PY + r"""
py -n %{ns} peerauthentication legacy-metrics '
s = o["spec"]
s.get("selector", {}).get("matchLabels") == {"app": "legacy-metrics"} and s.get("mtls", {}).get("mode") == "STRICT" and {str(k): v.get("mode") for k, v in s.get("portLevelMtls", {}).items()} == {"9090": "PERMISSIVE"}'"""),
C("from outside the mesh only port 9090 of legacy-metrics answers; inside it, everything still does", 4, NET + r"""
has -n istio-system peerauthentication default && has -n %{ns} peerauthentication legacy-metrics || exit 1
okr %{ns}-ext scraper http://legacy-metrics.%{ns}:9090 && okr %{ns} deploy/api http://legacy-metrics && okr %{ns} deploy/legacy-metrics http://api \
&& blocked %{ns}-ext scraper http://legacy-metrics.%{ns} && blocked %{ns}-ext scraper http://api.%{ns}"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata: {name: default, namespace: istio-system}
spec:
mtls: {mode: STRICT}
---
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata: {name: legacy-metrics, namespace: %{ns}}
spec:
selector:
matchLabels: {app: legacy-metrics}
mtls: {mode: STRICT}
portLevelMtls:
"9090": {mode: PERMISSIVE}
EOF
k -n %{ns}-ext exec scraper -- wget -T 3 -qO- http://legacy-metrics.%{ns}:9090 # answers
k -n %{ns}-ext exec scraper -- wget -T 3 -qO- http://legacy-metrics.%{ns} # reset
k -n %{ns}-ext exec scraper -- wget -T 3 -qO- http://api.%{ns} # reset
# The most specific policy wins: workload (with a selector) over Namespace over mesh. Port-level
# settings need the selector and name the workload's own port, not the Service's.
# docs: istio.io -> Reference -> Configuration -> Security -> PeerAuthentication.
""",
},
{
"id": 70, "domain": "MICRO", "title": "Mutual authentication in a CiliumNetworkPolicy",
"params": lambda r: {"ns": ns_pick(r, "ledger")},
"task": """
This cluster is being moved to Cilium, with mutual authentication between its services. The
CiliumNetworkPolicy API is already installed; no Cilium agent enforces it yet, so you are graded
on the object.
In Namespace `%{ns}`, create CiliumNetworkPolicy `db-mutual-auth`:
- it applies to endpoints labelled `app=db`;
- it allows incoming traffic only from endpoints labelled `app=api`, to TCP port 5432;
- and for that traffic it **requires mutual authentication**.
`k explain ciliumnetworkpolicy.spec.ingress` shows the fields.
""",
"setup": NS + r"""
mkns %{ns}
if ! $K get crd ciliumnetworkpolicies.cilium.io >/dev/null 2>&1; then
curl -sfL --max-time 60 https://raw.githubusercontent.com/cilium/cilium/v1.16.5/pkg/k8s/apis/cilium.io/client/crds/v2/ciliumnetworkpolicies.yaml | $K apply -f - >/dev/null 2>&1 \
|| { echo "could not install the CiliumNetworkPolicy CRD (needs github)" >&2; exit 1; }
fi
$K wait --for=condition=Established --timeout=60s crd/ciliumnetworkpolicies.cilium.io >/dev/null
""",
"checks": [
C("db-mutual-auth applies to app=db", 3, PY + r"""
py -n %{ns} ciliumnetworkpolicy db-mutual-auth 'o["spec"].get("endpointSelector", {}).get("matchLabels") == {"app": "db"}'"""),
C("it lets app=api in on TCP 5432, and nothing else", 3, PY + r"""
py -n %{ns} ciliumnetworkpolicy db-mutual-auth '
i = o["spec"].get("ingress", [])
ports = [(p.get("port"), p.get("protocol", "TCP").upper()) for r in i for tp in r.get("toPorts", []) for p in tp.get("ports", [])]
len(i) == 1 and [e.get("matchLabels") for e in i[0].get("fromEndpoints", [])] == [{"app": "api"}] and ports in ([("5432", "TCP")], [("5432", "ANY")])'"""),
C("and requires mutual authentication for it", 4, PY + r"""
py -n %{ns} ciliumnetworkpolicy db-mutual-auth 'len(o["spec"].get("ingress", [])) == 1 and o["spec"]["ingress"][0].get("authentication", {}).get("mode") == "required"'"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata: {name: db-mutual-auth, namespace: %{ns}}
spec:
endpointSelector:
matchLabels: {app: db}
ingress:
- fromEndpoints:
- matchLabels: {app: api}
authentication:
mode: required
toPorts:
- ports:
- {port: "5432", protocol: TCP}
EOF
# `authentication.mode: required` makes Cilium demand a successful mutual TLS handshake between
# the two workloads' identities (SPIFFE certificates) before the connection is allowed - on top of
# the label match. Encrypting the traffic itself is a separate, cluster-wide switch (WireGuard or
# IPsec: `cilium encrypt status`). docs.cilium.io -> Security -> Mutual Authentication.
""",
},
]