kubestronaut-sim/cks/bank_harden.py
vumas3r 0e5f7686ee feat: add guided lessons; move CKA and CKS to v1.37 on Ubuntu hosts
Add lessons: untimed guided labs, one per curriculum objective, on a
host of their own, in the browser and in the terminal. Each step has
a check, a hint and its commands; a lesson writes no history or
scores, only a done mark on its objective. CKAD 2.1-2.4 (24 steps)
and all 27 CKA objectives (135 steps) have one, and `./ksim lesson
audit` proves that every step fails, is done by its own commands,
then passes.

Add CKA tasks 132-147 (cka/bank_course.py) on what a full CKA course
teaches beyond the bank: a real upgrade from v1.36, a drain under a
PodDisruptionBudget, QoS and eviction, in-place resize, kube-proxy's
chains, encryption at rest, an operator, and CSI expansion, snapshots
and clones (ensure_csi_hostpath).

Move CKA and CKS to Kubernetes v1.37 and rebuild the hosts on Ubuntu
24.04, with kubelet, kubeadm and kubectl as held packages from
pkgs.k8s.io; the desktop runs Ubuntu too. Hosts gain man pages,
hexdump and xxd.

Fix CKS Q4, which gave 3 of 12 points untouched, and CKA Q4 and Q49,
whose solutions raced their new CRD.

Audited on v1.37: CKA 147/147, CKAD 105/105, CKS 109/110 (Q103, a
Falco task, failed to seed once and awaits a re-audit); lessons: 135
of 135 CKA steps and 24 of 24 CKAD steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:15:17 -06:00

881 lines
52 KiB
Python

"""CKS tasks 25-39: Cluster Hardening - the pool tasks that take the domain from 2 to 17.
2.1 RBAC 25 one Secret instead of all of them 26 a group that is cluster-admin
27 bind, escalate, impersonate, exec 28 who can read this Secret?
2.2 ServiceAccounts 29 a token for something else than the API 30 tokens that never expire
31 a ServiceAccount of its own
2.3 API access 32 the API answers without credentials 33 anonymous for health checks only
34 AlwaysAllow and no NodeRestriction 35 the API on a NodePort
36 a client certificate for an auditor
2.4 Upgrade 37 a worker on a vulnerable kubelet 38 upgrade the control plane
39 prepare an upgrade
Permissions are tested with `kubectl auth can-i` as the subject, tokens and certificates by calling
the API with them, and the API server's settings by what an unauthenticated or a kubelet's request
gets. 37 and 38 are real upgrades: CKS hosts carry Kubernetes as apt packages (images/node-cks),
so a node can be held back on an older patch release and upgraded with apt and kubeadm.
Every task has a scripted solution in solve.py.
"""
from cks.common import API, C, PY, NS, ns_pick, name_pick
from sim.bank import CAN # noqa: F401 (bank_runtime imports it from here)
# code URL-PATH [CURL-ARGS..]: the HTTP status the API server gives a request made on the control
# plane node - without credentials unless the arguments supply some.
CURL = r"""
code() { local p="$1"; shift; nx "$CP" "curl -sk -m 8 -o /dev/null -w '%{http_code}' $* https://localhost:6443$p" </dev/null; }
"""
# jwt FIELD < token: a claim of a ServiceAccount token (dotted path into its payload)
JWT = r"""
jwt() { python3 -c '
import base64, json, sys
t = sys.stdin.read().strip().split(".")
p = json.loads(base64.urlsafe_b64decode(t[1] + "=" * (-len(t[1]) % 4)))
for k in sys.argv[1].split("."):
p = p[k]
print(json.dumps(p) if isinstance(p, (list, dict)) else p)' "$1" 2>/dev/null; }
"""
# The apt repository of Kubernetes only (the node image has no package lists), then a patch release
# older than the node's for the three packages, held as kubeadm's documentation asks.
APT = r"""
K8S_LIST='-o Dir::Etc::sourcelist=/etc/apt/sources.list.d/kubernetes.list -o Dir::Etc::sourceparts=- -o APT::Get::List-Cleanup=0'
# hold_back NODE VERSION: put NODE's kubelet, kubeadm and kubectl on VERSION and restart its kubelet.
# pkgs.k8s.io has one repository per minor, so the node's repository line moves to VERSION's minor
# first, and stays there - as on a node installed with that minor.
hold_back() {
nx "$1" "sed -i 's#/core:/stable:/v[0-9.]*/deb/#/core:/stable:/v${2%.*}/deb/#' /etc/apt/sources.list.d/kubernetes.list
apt-get update -qq $K8S_LIST >/dev/null 2>&1
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --allow-downgrades --allow-change-held-packages -o Dpkg::Options::=--force-confold kubelet=$2-1.1 kubeadm=$2-1.1 kubectl=$2-1.1 >/dev/null 2>&1 || exit 1
apt-mark hold kubelet kubeadm kubectl >/dev/null; systemctl daemon-reload; systemctl restart kubelet" </dev/null || return 1
local i; for i in $(seq 1 40); do [ "$(j node "$1" '{.status.nodeInfo.kubeletVersion}')" = "v$2" ] && return 0; sleep 3; done; return 1
}
# pkgs NODE: "<kubelet version> <kubeadm version> <kubectl version>" as dpkg knows them
pkgs() { nx "$1" "dpkg-query -W -f='\${Version} ' kubelet kubeadm kubectl" </dev/null; }
held() { [ "$(nx "$1" 'apt-mark showhold | grep -c -x -e kubelet -e kubeadm -e kubectl' </dev/null)" = 3 ]; }
"""
USERS = ["dana", "omar", "priya", "lucas", "mei", "tariq"]
QUESTIONS = [
# ==================================================================== 2.1 RBAC ====
{
"id": 25, "domain": "HARDEN", "title": "One Secret instead of all of them",
"params": lambda r: {"ns": ns_pick(r, "billing"), "sa": name_pick(r, "invoicer")},
"task": """
ServiceAccount `%{sa}` in Namespace `%{ns}` reads its API key from Secret `api-key`. Role
`app-reader`, bound to it by RoleBinding `app-reader`, lets it read **every** Secret of the
Namespace — and list them, which shows their contents too.
Change the Role (keep its name, and the RoleBinding) so that `%{sa}`:
- may `get` Secret `api-key`, and nothing else about Secrets: no `list`, no `watch`, no other Secret;
- keeps `get`, `list` and `watch` on ConfigMaps.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} create sa %{sa} >/dev/null
$K -n %{ns} create secret generic api-key --from-literal=key=k-%{sa} >/dev/null
$K -n %{ns} create secret generic db-root --from-literal=password=root-%{sa} >/dev/null
$K -n %{ns} create role app-reader --verb=get,list,watch --resource=secrets,configmaps >/dev/null
$K -n %{ns} create rolebinding app-reader --role=app-reader --serviceaccount=%{ns}:%{sa} >/dev/null
""",
"checks": [
C("%{sa} can still get Secret api-key", 3, CAN + r"""
S=system:serviceaccount:%{ns}:%{sa}
can --as=$S -- get secret/api-key -n %{ns} && cannot --as=$S -- list secrets -n %{ns}"""),
C("it cannot get another Secret, nor list or watch Secrets", 4, CAN + r"""
S=system:serviceaccount:%{ns}:%{sa}
can --as=$S -- get secret/api-key -n %{ns} && cannot --as=$S -- get secret/db-root -n %{ns} \
&& cannot --as=$S -- list secrets -n %{ns} && cannot --as=$S -- watch secrets -n %{ns}"""),
C("ConfigMaps are still readable, through Role and RoleBinding app-reader", 3, CAN + r"""
S=system:serviceaccount:%{ns}:%{sa}
cannot --as=$S -- list secrets -n %{ns} || exit 1
for v in get list watch; do can --as=$S -- $v configmaps -n %{ns} || exit 1; done
eq "$(j -n %{ns} rolebinding app-reader '{.roleRef.name}')" app-reader && [ -n "$(j -n %{ns} role app-reader '{.metadata.name}')" ]"""),
],
"solution": """
k -n %{ns} get role app-reader -o yaml
cat <<EOF | k apply -f -
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: {name: app-reader, namespace: %{ns}}
rules:
- apiGroups: [""]
resources: [configmaps]
verbs: [get, list, watch]
- apiGroups: [""]
resources: [secrets]
resourceNames: [api-key]
verbs: [get]
EOF
k -n %{ns} auth can-i get secret/api-key --as=system:serviceaccount:%{ns}:%{sa} # yes
k -n %{ns} auth can-i get secret/db-root --as=system:serviceaccount:%{ns}:%{sa} # no
k -n %{ns} auth can-i list secrets --as=system:serviceaccount:%{ns}:%{sa} # no
# resourceNames narrows get, update, patch and delete to named objects. It cannot narrow list or
# watch, which return whole collections: a rule with resourceNames and `list` grants nothing
# useful, and `list` on secrets without it returns every value. Hence `get` only.
# The generator does it too: k create role X --verb=get --resource=secrets --resource-name=api-key
""",
},
{
"id": 26, "domain": "HARDEN", "title": "A group that is cluster-admin",
"params": lambda r: {"ns": ns_pick(r, "team"), "user": r.choice(USERS)},
"task": """
Everyone in group `dev` is cluster-admin, through ClusterRoleBinding `dev-team`. Replace that with
what the group needs, using the built-in ClusterRoles (create no Role of your own):
- in Namespace `%{ns}-dev`: `edit`, with RoleBinding `dev-edit`;
- in Namespace `%{ns}-prod`: `view`, with RoleBinding `dev-view`;
- nothing anywhere else.
Test as a member of the group: `--as=%{user} --as-group=dev`.
""",
"setup": NS + r"""
mkns %{ns}-dev; mkns %{ns}-prod
$K create clusterrolebinding dev-team --clusterrole=cluster-admin --group=dev >/dev/null
""",
"checks": [
C("group dev is no longer cluster-admin", 3, CAN + r"""
A="--as=%{user} --as-group=dev"
cannot $A -- '*' '*' && cannot $A -- list nodes && cannot $A -- get secrets -n kube-system"""),
C("in %{ns}-dev the group has edit, through RoleBinding dev-edit", 3, CAN + r"""
A="--as=%{user} --as-group=dev"
eq "$(j -n %{ns}-dev rolebinding dev-edit '{.roleRef.kind}/{.roleRef.name}')" ClusterRole/edit || exit 1
can $A -- create deployments -n %{ns}-dev && can $A -- delete pods -n %{ns}-dev && cannot $A -- create rolebindings -n %{ns}-dev"""),
C("in %{ns}-prod the group has view, through RoleBinding dev-view", 3, CAN + r"""
A="--as=%{user} --as-group=dev"
eq "$(j -n %{ns}-prod rolebinding dev-view '{.roleRef.kind}/{.roleRef.name}')" ClusterRole/view || exit 1
can $A -- list pods -n %{ns}-prod && cannot $A -- delete pods -n %{ns}-prod && cannot $A -- get secrets -n %{ns}-prod"""),
C("and nothing in any other Namespace", 1, CAN + r"""
A="--as=%{user} --as-group=dev"
can $A -- list pods -n %{ns}-prod && cannot $A -- list pods -n default && cannot $A -- list pods -n kube-system"""),
],
"solution": """
k get clusterrolebinding dev-team -o yaml # subjects: Group dev -> ClusterRole cluster-admin
k delete clusterrolebinding dev-team
k -n %{ns}-dev create rolebinding dev-edit --clusterrole=edit --group=dev
k -n %{ns}-prod create rolebinding dev-view --clusterrole=view --group=dev
k auth can-i '*' '*' --as=%{user} --as-group=dev # no
k -n %{ns}-dev auth can-i create deploy --as=%{user} --as-group=dev # yes
k -n %{ns}-prod auth can-i delete pods --as=%{user} --as-group=dev # no
# A RoleBinding may refer to a ClusterRole: the rules then hold in the RoleBinding's Namespace only.
# That is how the built-in roles (view, edit, admin) are meant to be handed out.
# `view` leaves out Secrets on purpose; `edit` leaves out Roles and RoleBindings.
""",
},
{
"id": 27, "domain": "HARDEN", "title": "bind, escalate, impersonate, exec",
"params": lambda r: {"ns": ns_pick(r, "release"), "user": r.choice(USERS)},
"task": """
Role `release-manager` in Namespace `%{ns}` is bound to user `%{user}`, who rolls out Deployments.
The Role carries permissions with which its holder can become anything in the Namespace.
Edit the Role in place (do not delete it, and leave the RoleBinding alone) and remove:
- every permission on Roles and RoleBindings — with `bind` and `escalate`, a user can grant
themselves whatever they like;
- `impersonate`;
- `pods/exec`;
- every permission on Secrets.
Keep: `get`, `list`, `watch`, `create`, `update`, `patch` on Deployments, and `get`, `list`,
`watch` on Pods.
""",
"setup": NS + r"""
mkns %{ns}
$K apply -f - >/dev/null <<EOF
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: {name: release-manager, namespace: %{ns}}
rules:
- {apiGroups: [apps], resources: [deployments], verbs: [get, list, watch, create, update, patch]}
- {apiGroups: [""], resources: [pods], verbs: [get, list, watch]}
- {apiGroups: [""], resources: [pods/exec], verbs: [create]}
- {apiGroups: [rbac.authorization.k8s.io], resources: [roles, rolebindings], verbs: [get, list, create, update, bind, escalate]}
- {apiGroups: [""], resources: [serviceaccounts], verbs: [impersonate]}
- {apiGroups: [""], resources: [secrets], verbs: [get, list]}
EOF
$K -n %{ns} create rolebinding release-manager --role=release-manager --user=%{user} >/dev/null
j -n %{ns} role release-manager '{.metadata.uid}' > "$PRIV/q27.uid"; [ -s "$PRIV/q27.uid" ]
""",
"checks": [
C("no permission on Roles and RoleBindings: not bind, not escalate, not create", 3, CAN + r"""
A="--as=%{user}"
for v in get create update bind escalate; do cannot $A -- $v roles.rbac.authorization.k8s.io -n %{ns} || exit 1; done
cannot $A -- create rolebindings.rbac.authorization.k8s.io -n %{ns}"""),
C("no impersonate, no pods/exec", 2, CAN + r"""
cannot --as=%{user} -- impersonate serviceaccounts -n %{ns} && cannot --as=%{user} -- create pods/exec -n %{ns}"""),
C("no access to Secrets", 2, CAN + r"""cannot --as=%{user} -- get secrets -n %{ns} && cannot --as=%{user} -- list secrets -n %{ns}"""),
C("the same Role still manages Deployments and reads Pods", 3, CAN + r"""
A="--as=%{user}"
eq "$(j -n %{ns} role release-manager '{.metadata.uid}')" "$(cat "$PRIV/q27.uid")" && cannot $A -- escalate roles.rbac.authorization.k8s.io -n %{ns} || exit 1
for v in get list watch create update patch; do can $A -- $v deployments.apps -n %{ns} || exit 1; done
for v in get list watch; do can $A -- $v pods -n %{ns} || exit 1; done"""),
],
"solution": """
k -n %{ns} edit role release-manager # delete the four rules; what stays:
# rules:
# - {apiGroups: [apps], resources: [deployments], verbs: [get, list, watch, create, update, patch]}
# - {apiGroups: [""], resources: [pods], verbs: [get, list, watch]}
k -n %{ns} auth can-i --list --as=%{user}
# Why each one is dangerous:
# bind create a RoleBinding to a role you do not hold yourself (to admin, say)
# escalate write rules into a Role that you do not hold yourself
# impersonate act as another user or ServiceAccount, with its permissions
# pods/exec a shell in any Pod - and with it that Pod's ServiceAccount token and Secrets
# Without bind and escalate the API server refuses to let anyone hand out more than they have.
""",
},
{
"id": 28, "domain": "HARDEN", "title": "Who can read this Secret?",
"params": lambda r: {"ns": ns_pick(r, "hr")},
"task": """
Secret `payroll` in Namespace `%{ns}` must be readable by as few identities as possible. The
Namespace has six ServiceAccounts: `builder`, `deployer`, `viewer`, `auditor`, `ingest` and
`reporter`.
1. Find out which of them can `get` Secret `payroll`, and write their names to
`{WS}/q28/can-read.txt`, one per line.
2. `auditor` has left the project: remove what gives **that** ServiceAccount its access to
Secrets — in every Namespace — without changing what the others can do.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q28"; mkdir -p "$WS/q28"
$K -n %{ns} create secret generic payroll --from-literal=iban=XX00-%{ns} >/dev/null
for s in builder deployer viewer auditor ingest reporter; do $K -n %{ns} create sa $s >/dev/null; done
$K -n %{ns} create role cfg-reader --verb=get,list --resource=configmaps >/dev/null
$K -n %{ns} create rolebinding builder --role=cfg-reader --serviceaccount=%{ns}:builder >/dev/null
$K -n %{ns} create rolebinding deployer --clusterrole=edit --serviceaccount=%{ns}:deployer >/dev/null
$K -n %{ns} create rolebinding viewer --clusterrole=view --serviceaccount=%{ns}:viewer >/dev/null
$K create clusterrole %{ns}-secret-audit --verb=get,list --resource=secrets >/dev/null
$K create clusterrolebinding %{ns}-secret-audit --clusterrole=%{ns}-secret-audit --serviceaccount=%{ns}:auditor >/dev/null
$K -n %{ns} create role ingest --verb=get --resource=secrets --resource-name=ingest-key >/dev/null
$K -n %{ns} create rolebinding ingest --role=ingest --serviceaccount=%{ns}:ingest >/dev/null
$K -n %{ns} create role payroll-reader --verb=get --resource=secrets --resource-name=payroll >/dev/null
$K -n %{ns} create rolebinding reporter --role=payroll-reader --serviceaccount=%{ns}:reporter >/dev/null
printf 'auditor\ndeployer\nreporter\n' > "$PRIV/q28.can"
""",
"checks": [
C("can-read.txt names exactly the ServiceAccounts that could read payroll", 5, r"""
[ -s "$WS/q28/can-read.txt" ] && diff <(tr -d ' \r' < "$WS/q28/can-read.txt" | sed 's#.*:##' | grep . | sort -u) "$PRIV/q28.can" >/dev/null"""),
C("auditor can no longer read Secrets, here or in any other Namespace", 3, CAN + r"""
S=system:serviceaccount:%{ns}:auditor
cannot --as=$S -- get secret/payroll -n %{ns} && cannot --as=$S -- list secrets -n %{ns} && cannot --as=$S -- get secrets -n kube-system"""),
C("the others are as they were: deployer and reporter can, the rest cannot", 2, CAN + r"""
P=system:serviceaccount:%{ns}
cannot --as=$P:auditor -- get secret/payroll -n %{ns} || exit 1
can --as=$P:deployer -- get secret/payroll -n %{ns} && can --as=$P:reporter -- get secret/payroll -n %{ns} \
&& can --as=$P:deployer -- create deployments -n %{ns} && can --as=$P:viewer -- list pods -n %{ns} \
&& cannot --as=$P:viewer -- get secret/payroll -n %{ns} && cannot --as=$P:ingest -- get secret/payroll -n %{ns} && cannot --as=$P:builder -- get secret/payroll -n %{ns}"""),
],
"solution": """
for sa in builder deployer viewer auditor ingest reporter; do
echo "$sa $(k -n %{ns} auth can-i get secret/payroll --as=system:serviceaccount:%{ns}:$sa)"
done # yes: deployer, auditor, reporter -> {WS}/q28/can-read.txt
k get rolebinding,clusterrolebinding -A -o wide | grep -w auditor # where its access comes from
k delete clusterrolebinding %{ns}-secret-audit
# Why each answer: deployer has `edit` (which includes Secrets), auditor a ClusterRoleBinding (all
# Namespaces), reporter a Role naming payroll. viewer has `view`, which leaves Secrets out; ingest
# may only get the Secret named ingest-key.
# Bindings are found by subject, not by name - `-o wide` prints the subjects.
""",
},
# ========================================================== 2.2 ServiceAccounts ====
{
"id": 29, "domain": "HARDEN", "title": "A token for something else than the API",
"params": lambda r: {"ns": ns_pick(r, "pay"), "pod": name_pick(r, "signer")},
"task": """
Pod `%{pod}` in Namespace `%{ns}` authenticates to an external vault with a ServiceAccount token.
That token must be short-lived and useless against the Kubernetes API.
Create ServiceAccount `vault-client` and Pod `%{pod}` (image `busybox:1.36`, command
`sleep 86400`) in `%{ns}`:
- the Pod runs as `vault-client`;
- the usual API token is **not** mounted;
- a projected ServiceAccount token is mounted at `/var/run/secrets/vault/token`, with audience
`vault` and a lifetime of 600 seconds.
The Pod must be Running.
""",
"setup": NS + r"""mkns %{ns}""",
"checks": [
C("the Pod runs as vault-client and mounts no API token", 3, r"""
eq "$(j -n %{ns} pod %{pod} '{.spec.serviceAccountName}')" vault-client && eq "$(j -n %{ns} pod %{pod} '{.status.phase}')" Running || exit 1
! $K -n %{ns} exec %{pod} -- test -e /var/run/secrets/kubernetes.io/serviceaccount/token 2>/dev/null"""),
C("a projected token volume: audience vault, 600 seconds, path token", 3, PY + r"""
py -n %{ns} pod %{pod} '
src = [s["serviceAccountToken"] for v in o["spec"]["volumes"] for s in v.get("projected", {}).get("sources", []) if "serviceAccountToken" in s]
any(s.get("audience") == "vault" and s.get("expirationSeconds") == 600 and s.get("path") == "token" for s in src)'"""),
C("the token in the Pod is for audience vault only and lives 10 minutes", 4, JWT + r"""
t=$($K -n %{ns} exec %{pod} -- cat /var/run/secrets/vault/token 2>/dev/null); [ -n "$t" ] || exit 1
eq "$(echo "$t" | jwt aud)" '["vault"]' && eq "$(echo "$t" | jwt sub)" system:serviceaccount:%{ns}:vault-client || exit 1
[ "$(( $(echo "$t" | jwt exp) - $(echo "$t" | jwt iat) ))" -le 660 ]"""),
],
"solution": """
k -n %{ns} create sa vault-client
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
serviceAccountName: vault-client
automountServiceAccountToken: false
containers:
- name: c
image: busybox:1.36
command: [sleep, "86400"]
volumeMounts:
- {name: vault-token, mountPath: /var/run/secrets/vault, readOnly: true}
volumes:
- name: vault-token
projected:
sources:
- serviceAccountToken: {path: token, audience: vault, expirationSeconds: 600}
EOF
k -n %{ns} exec %{pod} -- cat /var/run/secrets/vault/token | cut -d. -f2 | base64 -d 2>/dev/null; echo
# The API server only accepts tokens whose audience is its own, so a token for "vault" cannot be
# replayed against it. 600 seconds is the minimum; the kubelet replaces the file before it expires.
# docs: Configure Service Accounts for Pods -> "ServiceAccount token volume projection".
""",
},
{
"id": 30, "domain": "HARDEN", "title": "Tokens that never expire",
"params": lambda r: {"ns": ns_pick(r, "build")},
"task": """
Secret-based ServiceAccount tokens never expire: whoever copies one keeps their access for as long
as the Secret exists. Namespace `%{ns}` still has some.
1. Write the names of all Secrets of type `kubernetes.io/service-account-token` in `%{ns}` to
`{WS}/q30/legacy.txt`, one per line.
2. Delete them — and only them.
3. ServiceAccount `ci` still needs a token for tonight's job: issue one that is valid for
30 minutes and save it to `{WS}/q30/ci.token`.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q30"; mkdir -p "$WS/q30"
$K -n %{ns} create sa ci >/dev/null
$K -n %{ns} create role pod-reader --verb=get,list --resource=pods >/dev/null
$K -n %{ns} create rolebinding ci --role=pod-reader --serviceaccount=%{ns}:ci >/dev/null
$K -n %{ns} create secret generic ci-settings --from-literal=registry=registry.internal >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: v1
kind: Secret
metadata: {name: ci-token-2023, namespace: %{ns}, annotations: {kubernetes.io/service-account.name: ci}}
type: kubernetes.io/service-account-token
---
apiVersion: v1
kind: Secret
metadata: {name: default-token-legacy, namespace: %{ns}, annotations: {kubernetes.io/service-account.name: default}}
type: kubernetes.io/service-account-token
EOF
for i in $(seq 1 30); do t=$(j -n %{ns} secret ci-token-2023 '{.data.token}' | base64 -d); [ -n "$t" ] && break; sleep 1; done
[ -n "$t" ] || exit 1
echo "$t" > "$PRIV/q30.old"; printf 'ci-token-2023\ndefault-token-legacy\n' > "$PRIV/q30.legacy"
""",
"checks": [
C("legacy.txt names the two token Secrets", 3, r"""
[ -s "$WS/q30/legacy.txt" ] && diff <(tr -d ' \r' < "$WS/q30/legacy.txt" | sed 's#.*/##' | grep . | sort -u) "$PRIV/q30.legacy" >/dev/null"""),
C("both are deleted, Secret ci-settings is not, and the old token is refused", 3, CURL + r"""
[ -z "$(j -n %{ns} secret ci-token-2023 '{.metadata.name}')" ] && [ -z "$(j -n %{ns} secret default-token-legacy '{.metadata.name}')" ] \
&& [ -n "$(j -n %{ns} secret ci-settings '{.metadata.name}')" ] || exit 1
eq "$(code /api/v1/namespaces/%{ns}/pods -H "'Authorization: Bearer $(cat "$PRIV/q30.old")'")" 401"""),
C("ci.token is a token for ServiceAccount ci, valid for 30 minutes, that the API accepts", 4, JWT + CURL + r"""
t=$(tr -d ' \n\r' < "$WS/q30/ci.token" 2>/dev/null); [ -n "$t" ] || exit 1
eq "$(echo "$t" | jwt sub)" system:serviceaccount:%{ns}:ci && eq "$(( $(echo "$t" | jwt exp) - $(echo "$t" | jwt iat) ))" 1800 || exit 1
eq "$(code /api/v1/namespaces/%{ns}/pods -H "'Authorization: Bearer $t'")" 200"""),
],
"solution": """
k -n %{ns} get secret --field-selector type=kubernetes.io/service-account-token
k -n %{ns} get secret --field-selector type=kubernetes.io/service-account-token -o name | cut -d/ -f2 > {WS}/q30/legacy.txt
k -n %{ns} delete secret $(cat {WS}/q30/legacy.txt)
k -n %{ns} create token ci --duration=30m > {WS}/q30/ci.token
cut -d. -f2 {WS}/q30/ci.token | base64 -d 2>/dev/null; echo # exp - iat = 1800
# Deleting the Secret revokes the token at once: the API server checks that the Secret still exists.
# `create token` asks the TokenRequest API for a signed, time-limited token that is stored nowhere.
# Since v1.24 no token Secret is created for a ServiceAccount automatically - any you find was made
# by hand or is left over.
""",
},
{
"id": 31, "domain": "HARDEN", "title": "A ServiceAccount of its own",
"params": lambda r: {"ns": ns_pick(r, "ops")},
"task": """
Deployment `reporter` in Namespace `%{ns}` runs as the Namespace's `default` ServiceAccount, which
somebody bound to ClusterRole `edit` (RoleBinding `default-edit`). All `reporter` does with the
API is read ConfigMaps and create Events.
1. Create ServiceAccount `reporter`, Role `reporter` and RoleBinding `reporter` in `%{ns}` with
exactly that: `get`, `list`, `watch` on ConfigMaps and `create` on Events.
2. Make the Deployment run as `reporter`.
3. Take the `default` ServiceAccount's rights away, and stop it from mounting its token into Pods
automatically.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} create rolebinding default-edit --clusterrole=edit --serviceaccount=%{ns}:default >/dev/null
$K -n %{ns} create deploy reporter --image=busybox:1.36 -- sleep 86400 >/dev/null
$K -n %{ns} rollout status deploy reporter --timeout=180s >/dev/null
""",
"checks": [
C("the Deployment's Pods run as ServiceAccount reporter", 3, PY + r"""
eq "$(j -n %{ns} deploy reporter '{.spec.template.spec.serviceAccountName}')" reporter || exit 1
for i in $(seq 1 20); do
py -n %{ns} pod -l app=reporter '
p = [x for x in o["items"] if "deletionTimestamp" not in x["metadata"]]
len(p) == 1 and p[0]["spec"]["serviceAccountName"] == "reporter" and p[0]["status"].get("phase") == "Running"' && exit 0
sleep 3
done; exit 1"""),
C("reporter may read ConfigMaps and create Events, and nothing more", 3, CAN + r"""
S=system:serviceaccount:%{ns}:reporter
for v in get list watch; do can --as=$S -- $v configmaps -n %{ns} || exit 1; done
can --as=$S -- create events -n %{ns} && cannot --as=$S -- get secrets -n %{ns} && cannot --as=$S -- delete configmaps -n %{ns} \
&& cannot --as=$S -- create pods -n %{ns} && cannot --as=$S -- list configmaps -n default"""),
C("the default ServiceAccount has no rights left", 2, CAN + r"""
S=system:serviceaccount:%{ns}:default
[ -n "$(j -n %{ns} sa reporter '{.metadata.name}')" ] && cannot --as=$S -- list configmaps -n %{ns} && cannot --as=$S -- get pods -n %{ns} && cannot --as=$S -- get secrets -n %{ns}"""),
C("and no longer mounts its token automatically", 2, r"""eq "$(j -n %{ns} sa default '{.automountServiceAccountToken}')" false"""),
],
"solution": """
k -n %{ns} create sa reporter
k -n %{ns} create role reporter --verb=get,list,watch --resource=configmaps
k -n %{ns} edit role reporter # add: - {apiGroups: [""], resources: [events], verbs: [create]}
k -n %{ns} create rolebinding reporter --role=reporter --serviceaccount=%{ns}:reporter
k -n %{ns} set serviceaccount deploy reporter reporter
k -n %{ns} delete rolebinding default-edit
k -n %{ns} patch sa default -p '{"automountServiceAccountToken": false}'
k -n %{ns} auth can-i --list --as=system:serviceaccount:%{ns}:reporter
# `create role` takes one set of verbs for all its resources, so two rules with different verbs
# need an edit (or a YAML file). The default ServiceAccount is what every Pod gets when nobody
# says otherwise: it should be able to do nothing, and need not hand out a token.
""",
},
# ======================================================= 2.3 Restrict API access ====
{
"id": 32, "domain": "HARDEN", "title": "The API answers without credentials",
"params": lambda r: {"ns": ns_pick(r, "payroll")},
"task": """
Anyone who can reach this cluster's API server can read it without logging in. On `{CP}`:
curl -k https://localhost:6443/api/v1/namespaces/%{ns}/secrets
1. Save the response of that request to `{WS}/q32/before.json`.
2. Find every ClusterRoleBinding and RoleBinding that grants something to user
`system:anonymous` or to group `system:unauthenticated`. Write the names of those that are
**not** part of Kubernetes itself to `{WS}/q32/bindings.txt`, one per line.
3. Delete them. Kubernetes' own bindings (their names start with `system:`) stay: `/version` and
the health endpoints must keep answering anonymous requests.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q32"; mkdir -p "$WS/q32"
$K -n %{ns} create secret generic salaries --from-literal=total=%{ns}-4711 >/dev/null
$K create clusterrole all-secrets-reader --verb=get,list --resource=secrets >/dev/null
$K create clusterrolebinding monitoring-anon --clusterrole=all-secrets-reader --user=system:anonymous >/dev/null
$K create clusterrolebinding legacy-discovery --clusterrole=view --group=system:unauthenticated >/dev/null
$K -n %{ns} create rolebinding guest-access --clusterrole=edit --group=system:unauthenticated >/dev/null
printf 'guest-access\nlegacy-discovery\nmonitoring-anon\n' > "$PRIV/q32.bindings"
""",
"checks": [
C("before.json shows the Secrets an anonymous request could list", 2, r"""grep -q '"salaries"' "$WS/q32/before.json" 2>/dev/null && grep -q SecretList "$WS/q32/before.json" """),
C("bindings.txt names the three bindings", 3, r"""
[ -s "$WS/q32/bindings.txt" ] && diff <(tr -d ' \r' < "$WS/q32/bindings.txt" | sed 's#.*/##' | grep . | sort -u) "$PRIV/q32.bindings" >/dev/null"""),
C("anonymous requests for Secrets and Pods now get 403", 3, CURL + r"""
eq "$(code /api/v1/namespaces/%{ns}/secrets)" 403 && eq "$(code /api/v1/namespaces/%{ns}/pods)" 403 && eq "$(code /api/v1/namespaces/default/configmaps)" 403"""),
C("while /version and /readyz still answer them", 2, CURL + r"""
eq "$(code /api/v1/namespaces/%{ns}/secrets)" 403 && eq "$(code /version)" 200 && eq "$(code /readyz)" 200"""),
],
"solution": """
curl -sk https://localhost:6443/api/v1/namespaces/%{ns}/secrets > {WS}/q32/before.json
k get clusterrolebinding,rolebinding -A -o wide | grep -E 'system:anonymous|system:unauthenticated'
# monitoring-anon, legacy-discovery (cluster-wide), guest-access (in %{ns}) -> {WS}/q32/bindings.txt
# system:public-info-viewer is Kubernetes' own: it lets anyone read /version, /healthz, /livez, /readyz
k delete clusterrolebinding monitoring-anon legacy-discovery
k -n %{ns} delete rolebinding guest-access
curl -sk https://localhost:6443/api/v1/namespaces/%{ns}/secrets | grep -E 'code|message' # 403 Forbidden
curl -sk https://localhost:6443/version | head -3 # still answers
# A request without credentials is not rejected: it becomes user system:anonymous in group
# system:unauthenticated, and RBAC decides. 403 means "I know who you are (nobody), and you may
# not"; 401 would mean anonymous requests are switched off altogether.
""",
},
{
"id": 33, "domain": "HARDEN", "title": "Anonymous access for health checks only", "difficulty": "hard",
"task": """
Anonymous requests to this cluster's API server are to be accepted for the health endpoints only.
Switching them off altogether (`--anonymous-auth=false`) is not an option: the kubelet probes the
API server's static Pod at `/livez` and `/readyz` without credentials.
On `{CP}`:
1. Write an `AuthenticationConfiguration` (API group `apiserver.config.k8s.io`) to
`/etc/kubernetes/auth/authn.yaml` that enables anonymous requests for the paths `/livez`,
`/readyz` and `/healthz`, and for nothing else.
2. Make the API server use it (`--authentication-config`); the file has to be visible inside
the static Pod.
3. When the API server is back, save the HTTP status code an anonymous request to
`https://localhost:6443/version` gets to `{WS}/q33/version.txt`.
""",
"setup": r"""
rm -rf "$WS/q33"; mkdir -p "$WS/q33"
nx "$CP" 'rm -rf /etc/kubernetes/auth' </dev/null
""",
"checks": [
C("authn.yaml allows anonymous requests for the three health paths only", 2, r"""
f=/etc/kubernetes/auth/authn.yaml
eq "$(nx "$CP" "yq .kind $f" </dev/null)" AuthenticationConfiguration && eq "$(nx "$CP" "yq .anonymous.enabled $f" </dev/null)" true \
&& eq "$(nx "$CP" "yq '[.anonymous.conditions[].path] | sort | join(\",\")' $f" </dev/null)" /healthz,/livez,/readyz"""),
C("the API server runs with --authentication-config and is healthy", 3, API + r"""
runs kube-apiserver '--authentication-config=/etc/kubernetes/auth/authn.yaml' && wait_api 90 && pod_ready kube-system "kube-apiserver-$CP" 90"""),
C("anonymous: /livez and /readyz answer 200, /version and /api get 401", 4, API + CURL + r"""
runs kube-apiserver '--authentication-config=' && wait_api 60 || exit 1
eq "$(code /livez)" 200 && eq "$(code /readyz)" 200 && eq "$(code /version)" 401 && eq "$(code /api/v1/namespaces)" 401"""),
C("version.txt says 401", 1, r"""eq "$(tr -d ' \n\r' < "$WS/q33/version.txt" 2>/dev/null)" 401"""),
],
"solution": """
sudo -i
mkdir -p /etc/kubernetes/auth
cat > /etc/kubernetes/auth/authn.yaml <<EOF
apiVersion: apiserver.config.k8s.io/v1
kind: AuthenticationConfiguration
anonymous:
enabled: true
conditions:
- path: /livez
- path: /readyz
- path: /healthz
EOF
cp /etc/kubernetes/manifests/kube-apiserver.yaml /root/ # a backup outside manifests/
vi /etc/kubernetes/manifests/kube-apiserver.yaml
# command: - --authentication-config=/etc/kubernetes/auth/authn.yaml
# volumeMounts: - {name: authn, mountPath: /etc/kubernetes/auth, readOnly: true}
# volumes: - {name: authn, hostPath: {path: /etc/kubernetes/auth, type: DirectoryOrCreate}}
watch crictl ps # until kube-apiserver is back
curl -sk -o /dev/null -w '%{http_code}\\n' https://localhost:6443/livez # 200
curl -sk -o /dev/null -w '%{http_code}\\n' https://localhost:6443/version | tee {WS}/q33/version.txt # 401
# docs: Authenticating -> "Anonymous Authenticator Configuration". The file and --anonymous-auth
# exclude each other: with both, the API server refuses to start. If it does not come back, the
# reason is in `crictl logs $(crictl ps -a --name kube-apiserver -q | head -1)`.
""",
},
{
"id": 34, "domain": "HARDEN", "title": "AlwaysAllow, and kubelets that may change anything", "workers": 1, "difficulty": "hard",
"task": """
The API server of this cluster authorises every request (`--authorization-mode=AlwaysAllow`), and
nothing restricts what a kubelet may change.
1. Set the authorisation mode back to `Node,RBAC`.
2. Enable the `NodeRestriction` admission plugin.
3. When the API server is back, prove it: with the kubelet's own credentials on `{W1}`
(`/etc/kubernetes/kubelet.conf` there), try to label the **control-plane** node:
kubectl --kubeconfig /etc/kubernetes/kubelet.conf label node {CP} audit=failed
Save the error message to `{WS}/q34/denied.txt`.
""",
"setup": API + r"""
rm -rf "$WS/q34"; mkdir -p "$WS/q34"
medit kube-apiserver.yaml '(.spec.containers[0].command[] | select(. == "--authorization-mode=Node,RBAC")) = "--authorization-mode=AlwaysAllow" | .spec.containers[0].command |= map(select(test("^--enable-admission-plugins=") | not))' || exit 1
wait_proc kube-apiserver '--authorization-mode=AlwaysAllow' && wait_api
""",
"checks": [
C("the API server runs with --authorization-mode=Node,RBAC", 3, API + r"""
eq "$(flag kube-apiserver authorization-mode)" Node,RBAC && runs kube-apiserver '--authorization-mode=Node,RBAC' && wait_api 90"""),
C("and with the NodeRestriction admission plugin", 3, API + r"""
flag kube-apiserver enable-admission-plugins | tr ',' '\n' | grep -qx NodeRestriction \
&& runs kube-apiserver '--enable-admission-plugins=[^ ]*NodeRestriction' && wait_api 90"""),
C("the kubelet of {W1} can update its own Node but not label another one", 3, API + r"""
runs kube-apiserver '--authorization-mode=Node,RBAC' && wait_api 60 || exit 1
KC='kubectl --kubeconfig /etc/kubernetes/kubelet.conf'
nx "$W1" "$KC get node $W1 -o name" </dev/null >/dev/null 2>&1 || exit 1 # its credentials work at all
out=$(nx "$W1" "$KC label node $CP ksim-probe=1 --overwrite" </dev/null 2>&1); rc=$?
$K label node "$CP" ksim-probe- >/dev/null 2>&1
[ $rc -ne 0 ] && echo "$out" | grep -qi forbidden"""),
C("denied.txt holds the refusal", 1, r"""grep -qi 'forbidden' "$WS/q34/denied.txt" 2>/dev/null"""),
],
"solution": """
sudo cp /etc/kubernetes/manifests/kube-apiserver.yaml /root/
sudo vi /etc/kubernetes/manifests/kube-apiserver.yaml
# - --authorization-mode=Node,RBAC
# - --enable-admission-plugins=NodeRestriction
watch sudo crictl ps # until kube-apiserver is back
ssh {W1} sudo kubectl --kubeconfig /etc/kubernetes/kubelet.conf label node {CP} audit=failed 2>&1 | tee {WS}/q34/denied.txt
# Node authorisation lets a kubelet read what the Pods on its node need; NodeRestriction (an
# admission plugin - it goes in --enable-admission-plugins, not in the authorisation modes) lets
# it modify only its own Node object and the Pods bound to it. Without it, a compromised node can
# relabel other nodes and attract their workloads. Order matters in Node,RBAC only for speed.
""",
},
{
"id": 35, "domain": "HARDEN", "title": "The API server on a NodePort",
"task": """
The API server of this cluster is published on port **31000** of every node: Service `kubernetes`
in Namespace `default` is of type `NodePort`.
1. Find what makes it so and remove it from the API server's configuration.
2. Service `kubernetes` must be of type `ClusterIP` again. (The API server maintains that Service
itself — if you delete it, it is created anew.)
""",
"setup": API + r"""
medit kube-apiserver.yaml '.spec.containers[0].command += ["--kubernetes-service-node-port=31000"]' || exit 1
wait_proc kube-apiserver '--kubernetes-service-node-port=31000' && wait_api || exit 1
# the flag only takes effect when the API server creates its Service: let it create it again
$K -n default delete svc kubernetes >/dev/null 2>&1
for i in $(seq 1 30); do [ "$(j -n default svc kubernetes '{.spec.type}')" = NodePort ] && exit 0; sleep 2; done
echo "Service kubernetes did not become a NodePort" >&2; exit 1
""",
"checks": [
C("the API server no longer runs with --kubernetes-service-node-port, and is healthy", 4, API + r"""
[ -z "$(flag kube-apiserver kubernetes-service-node-port)" ] && ! runs kube-apiserver '--kubernetes-service-node-port' && wait_api 90 \
&& pod_ready kube-system "kube-apiserver-$CP" 90"""),
C("Service kubernetes is of type ClusterIP, without a node port", 4, API + r"""
! runs kube-apiserver '--kubernetes-service-node-port' || exit 1
for i in $(seq 1 10); do [ "$(j -n default svc kubernetes '{.spec.type}')" = ClusterIP ] && break; sleep 3; done
eq "$(j -n default svc kubernetes '{.spec.type}')" ClusterIP && [ -z "$(j -n default svc kubernetes '{.spec.ports[0].nodePort}')" ]"""),
C("port 31000 of the node no longer answers, port 6443 does", 2, r"""
eq "$(j -n default svc kubernetes '{.spec.type}')" ClusterIP && eq "$(nx "$CP" "curl -sk -m 5 -o /dev/null -w '%{http_code}' https://$CP:6443/version" </dev/null)" 200 || exit 1
# kube-proxy may need most of a minute to notice, after the API server it watches was restarted
for i in $(seq 1 25); do nx "$CP" "curl -sk -m 3 -o /dev/null https://$CP:31000/version" </dev/null || exit 0; sleep 3; done; exit 1"""),
],
"solution": """
k get svc kubernetes # NodePort 443:31000
sudo grep -n node-port /etc/kubernetes/manifests/kube-apiserver.yaml
sudo vi /etc/kubernetes/manifests/kube-apiserver.yaml # delete: - --kubernetes-service-node-port=31000
watch sudo crictl ps # until kube-apiserver is back
k delete svc kubernetes # the API server re-creates it, as ClusterIP
k get svc kubernetes
# The flag makes the API server create its own Service as a NodePort. Removing the flag does not
# change the existing Service - deleting it does, and is safe: the reconciler puts it back within
# seconds. Editing the Service's type by hand works too.
""",
},
{
"id": 36, "domain": "HARDEN", "title": "A client certificate for an auditor",
"params": lambda r: {"ns": ns_pick(r, "ledger"), "user": r.choice(USERS)},
"task": """
Auditor `%{user}` needs read-only access to Namespace `%{ns}` for one day, with a client certificate.
1. Create a private key `{WS}/q36/%{user}.key` (RSA, 2048 bits) and a certificate request for
common name `%{user}` and organisation `auditors`.
2. Submit it as CertificateSigningRequest `%{user}`: signer `kubernetes.io/kube-apiserver-client`,
usage `client auth`, valid for 86400 seconds. Approve it and save the issued certificate to
`{WS}/q36/%{user}.crt`.
3. Give group `auditors` the built-in ClusterRole `view` in Namespace `%{ns}` only, with
RoleBinding `auditors-view`.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q36"; mkdir -p "$WS/q36"
$K -n %{ns} create secret generic books --from-literal=k=v >/dev/null
$K delete csr %{user} --ignore-not-found >/dev/null 2>&1
""",
"checks": [
C("CertificateSigningRequest %{user}: client signer, client auth, 86400 seconds, approved", 2, PY + r"""
py csr %{user} '
s = o["spec"]
s["signerName"] == "kubernetes.io/kube-apiserver-client" and "client auth" in s.get("usages", []) and s.get("expirationSeconds") == 86400 and any(c["type"] == "Approved" for c in o.get("status", {}).get("conditions", []))'"""),
C("%{user}.crt: CN %{user}, O auditors, signed by the cluster's CA, for one day, matching the key", 3, r"""
d=/opt/course/q36
nx "$CP" "openssl verify -CAfile /etc/kubernetes/pki/ca.crt $d/%{user}.crt" </dev/null >/dev/null 2>&1 || exit 1
s=$(nx "$CP" "openssl x509 -in $d/%{user}.crt -noout -subject -nameopt RFC2253" </dev/null)
echo "$s" | grep -q 'CN=%{user}\(,\|$\)' && echo "$s" | grep -q 'O=auditors\(,\|$\)' || exit 1
eq "$(nx "$CP" "openssl x509 -in $d/%{user}.crt -noout -pubkey | sha256sum" </dev/null)" "$(nx "$CP" "openssl pkey -in $d/%{user}.key -pubout | sha256sum" </dev/null)" || exit 1
a=$(date -d "$(nx "$CP" "openssl x509 -in $d/%{user}.crt -noout -startdate" </dev/null | cut -d= -f2)" +%s)
b=$(date -d "$(nx "$CP" "openssl x509 -in $d/%{user}.crt -noout -enddate" </dev/null | cut -d= -f2)" +%s)
[ $(( b - a )) -le 87000 ]"""),
C("with it, %{user} lists Pods in %{ns} - and gets no Secrets and no other Namespace", 3, CURL + r"""
A="--cert /opt/course/q36/%{user}.crt --key /opt/course/q36/%{user}.key"
eq "$(code /api/v1/namespaces/%{ns}/pods $A)" 200 && eq "$(code /api/v1/namespaces/%{ns}/secrets $A)" 403 && eq "$(code /api/v1/namespaces/default/pods $A)" 403"""),
C("RoleBinding auditors-view gives group auditors the ClusterRole view", 2, PY + r"""
py -n %{ns} rolebinding auditors-view '
o["roleRef"]["kind"] == "ClusterRole" and o["roleRef"]["name"] == "view" and [(s["kind"], s["name"]) for s in o.get("subjects", [])] == [("Group", "auditors")]'"""),
],
"solution": """
cd {WS}/q36
openssl genrsa -out %{user}.key 2048
openssl req -new -key %{user}.key -subj "/CN=%{user}/O=auditors" -out %{user}.csr
cat <<EOF | k apply -f -
apiVersion: certificates.k8s.io/v1
kind: CertificateSigningRequest
metadata: {name: %{user}}
spec:
request: $(base64 -w0 < %{user}.csr)
signerName: kubernetes.io/kube-apiserver-client
expirationSeconds: 86400
usages: [client auth]
EOF
k certificate approve %{user}
# the signer issues the certificate a moment AFTER the approval - read it too early and the file is empty
until [ -n "$(k get csr %{user} -o jsonpath='{.status.certificate}')" ]; do sleep 1; done
k get csr %{user} -o jsonpath='{.status.certificate}' | base64 -d > %{user}.crt
k -n %{ns} create rolebinding auditors-view --clusterrole=view --group=auditors
openssl x509 -in %{user}.crt -noout -subject -dates
# CN becomes the user name, O the group. The request field is the base64 of the whole PEM file on
# one line (-w0). There is no revoking a client certificate: its short life and the RoleBinding
# of the group are what you control. docs: "Certificates and Certificate Signing Requests".
""",
},
# ================================================================= 2.4 Upgrade ====
{
"id": 37, "domain": "HARDEN", "title": "A worker on a vulnerable kubelet", "workers": 1, "difficulty": "hard",
"params": lambda r: {"ns": ns_pick(r, "web"), "old": r.choice(["1.36.3", "1.36.4", "1.36.5"])},
"task": """
Node `{W1}` still runs Kubernetes **v%{old}**, a release with known vulnerabilities. The control
plane is newer (`k get nodes`).
Upgrade `{W1}` to the control plane's version, the documented way:
1. Drain `{W1}`.
2. On `{W1}`: upgrade the `kubeadm` package, run `kubeadm upgrade node`, upgrade `kubelet` and
`kubectl`, and restart the kubelet.
3. Make the node schedulable again.
The three packages are held (`apt-mark`) and must be held again when you are done. Deployment
`web` in `%{ns}` must be available afterwards.
""",
"setup": NS + API + APT + r"""
mkns %{ns}
hold_back "$W1" %{old} || { echo "could not put $W1 on v%{old}" >&2; exit 1; }
$K -n %{ns} create deploy web --image=nginx:1.27 --replicas=2 >/dev/null
$K -n %{ns} rollout status deploy web --timeout=240s >/dev/null && node_ready "$W1" 60
""",
"checks": [
C("{W1} runs the control plane's Kubernetes version and is Ready", 4, API + r"""
v=$(j node "$CP" '{.status.nodeInfo.kubeletVersion}'); [ -n "$v" ] || exit 1
for i in $(seq 1 10); do [ "$(j node "$W1" '{.status.nodeInfo.kubeletVersion}')" = "$v" ] && break; sleep 3; done
eq "$(j node "$W1" '{.status.nodeInfo.kubeletVersion}')" "$v" && node_ready "$W1" 30"""),
C("kubelet, kubeadm and kubectl packages on {W1} are at that version", 2, APT + r"""
v=$(j node "$CP" '{.status.nodeInfo.kubeletVersion}'); v=${v#v}; [ -n "$v" ] || exit 1
eq "$(pkgs "$W1")" "$v-1.1 $v-1.1 $v-1.1 " """),
C("and are held again", 2, APT + r"""
v=$(j node "$CP" '{.status.nodeInfo.kubeletVersion}'); eq "$(pkgs "$W1" | cut -d' ' -f1)" "${v#v}-1.1" && held "$W1" """),
C("{W1} is schedulable and Deployment web is available", 2, r"""
eq "$(j node "$W1" '{.status.nodeInfo.kubeletVersion}')" "$(j node "$CP" '{.status.nodeInfo.kubeletVersion}')" || exit 1
[ "$(j node "$W1" '{.spec.unschedulable}')" != true ] || exit 1
for i in $(seq 1 20); do [ "$(j -n %{ns} deploy web '{.status.availableReplicas}')" = 2 ] && exit 0; sleep 3; done; exit 1"""),
],
"solution": """
k get nodes # {W1}: v%{old}; the control plane's version is the target, say 1.37.X
k drain {W1} --ignore-daemonsets --delete-emptydir-data
{SHELL_W1}
sudo -i
cat /etc/apt/sources.list.d/kubernetes.list # .../core:/stable:/v1.36/deb/ - the node's minor
sed -i 's|/v1.36/|/v1.37/|' /etc/apt/sources.list.d/kubernetes.list
apt-get update && apt-cache madison kubeadm | head -5 # the package versions on offer
apt-mark unhold kubeadm && apt-get install -y kubeadm=1.37.X-1.1 && apt-mark hold kubeadm
kubeadm upgrade node # a worker: `node`, not `apply`
apt-mark unhold kubelet kubectl && apt-get install -y kubelet=1.37.X-1.1 kubectl=1.37.X-1.1 && apt-mark hold kubelet kubectl
systemctl daemon-reload && systemctl restart kubelet
exit; exit
k uncordon {W1}
k get nodes # both on the same version, Ready
# docs: "Upgrading kubeadm clusters" -> "Changing the package repository", then "Upgrade worker
# nodes". {W1} is a minor behind, and pkgs.k8s.io serves one repository per minor: until the line
# names v1.37, apt offers nothing newer than 1.36. kubeadm first, because it rewrites the kubelet's
# configuration for the new version; the kubelet package last, then the restart - `k get nodes`
# shows the version of the running kubelet, not of the package.
""",
},
{
"id": 38, "domain": "HARDEN", "title": "Upgrade the control plane", "workers": 0, "difficulty": "hard",
"task": """
A vulnerability in the API server was fixed in a newer patch release of Kubernetes v1.37.
Upgrade this cluster — a single control-plane node, `{CP}` — to the **newest v1.37 patch release**
that the node's package repository offers:
1. Upgrade the `kubeadm` package, look at `kubeadm upgrade plan`, and apply the upgrade.
2. Drain the node, upgrade `kubelet` and `kubectl`, restart the kubelet, make the node
schedulable again.
The three packages are held (`apt-mark`) and must be held again when you are done.
""",
"setup": API + APT + r"""
nx "$CP" "apt-get update -qq $K8S_LIST >/dev/null 2>&1; apt-cache madison kubeadm | head -1" </dev/null | awk '{print $3}' > "$PRIV/q38.target"
t=$(cat "$PRIV/q38.target"); cur=$(pkgs "$CP" | cut -d' ' -f1)
[ -n "$t" ] && [ -n "$cur" ] || { echo "no package versions on $CP" >&2; exit 1; }
[ "$t" != "$cur" ] || { echo "the repository has nothing newer than $cur: there is no upgrade to do" >&2; exit 1; }
""",
"checks": [
C("the API server runs the newest v1.37 patch release", 4, API + r"""
t=$(cat "$PRIV/q38.target"); wait_api 60 || exit 1
eq "$($K version -o json 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin)["serverVersion"]["gitVersion"])')" "v${t%%-*}" """),
C("the node's kubelet does too, and the node is Ready and schedulable", 3, API + r"""
t=$(cat "$PRIV/q38.target")
for i in $(seq 1 10); do [ "$(j node "$CP" '{.status.nodeInfo.kubeletVersion}')" = "v${t%%-*}" ] && break; sleep 3; done
eq "$(j node "$CP" '{.status.nodeInfo.kubeletVersion}')" "v${t%%-*}" && node_ready "$CP" 30 && [ "$(j node "$CP" '{.spec.unschedulable}')" != true ]"""),
C("kubelet, kubeadm and kubectl packages are at that version and held", 2, APT + r"""
t=$(cat "$PRIV/q38.target"); eq "$(pkgs "$CP")" "$t $t $t " && held "$CP" """),
C("controller manager and scheduler run the new images", 1, r"""
t=$(cat "$PRIV/q38.target"); v="v${t%%-*}"
j -n kube-system pod "kube-controller-manager-$CP" '{.spec.containers[0].image}' | grep -q ":$v$" \
&& j -n kube-system pod "kube-scheduler-$CP" '{.spec.containers[0].image}' | grep -q ":$v$" """),
],
"solution": """
sudo -i
apt-get update && apt-cache madison kubeadm | head -3 # newest first, say 1.37.X-1.1
apt-mark unhold kubeadm && apt-get install -y kubeadm=1.37.X-1.1 && apt-mark hold kubeadm
kubeadm version -o short
kubeadm upgrade plan # what would change, and to which version
kubeadm upgrade apply v1.37.X # a few minutes; answers y
kubectl drain {CP} --ignore-daemonsets --delete-emptydir-data
apt-mark unhold kubelet kubectl && apt-get install -y kubelet=1.37.X-1.1 kubectl=1.37.X-1.1 && apt-mark hold kubelet kubectl
systemctl daemon-reload && systemctl restart kubelet
kubectl uncordon {CP}
kubectl get nodes; kubectl version
# docs: "Upgrading kubeadm clusters". `upgrade apply` replaces the static Pod manifests of the
# API server, controller manager and scheduler, and upgrades CoreDNS and kube-proxy; the kubelet
# is a package and stays old until you install and restart it. On a cluster with more nodes: the
# other control planes with `kubeadm upgrade node`, then the workers one at a time.
""",
},
{
"id": 39, "domain": "HARDEN", "title": "Prepare an upgrade", "workers": 2,
"params": lambda r: {"old": r.choice(["1.36.3", "1.36.4", "1.36.5"]), "n": r.choice([1, 2])},
"task": """
A CVE affects every kubelet older than the version this cluster's control plane runs. Prepare the
upgrade — do **not** upgrade anything yet.
1. Write the names of the nodes whose kubelet is older than the control plane's to
`{WS}/q39/outdated.txt`, one per line.
2. Cordon those nodes, so that no new Pod lands on a vulnerable kubelet. Do not drain them, and
leave the other nodes schedulable.
3. Write the newest v1.37 package version of `kubeadm` that the repository on `{CP}` offers
(as apt prints it, for example `1.37.2-1.1`) to `{WS}/q39/target.txt`.
4. Save the output of `kubeadm upgrade plan` on `{CP}` to `{WS}/q39/plan.txt`.
""",
"setup": API + APT + r"""
rm -rf "$WS/q39"; mkdir -p "$WS/q39"
old=$([ %{n} = 1 ] && echo "$W1" || echo "$W2")
hold_back "$old" %{old} || { echo "could not put $old on v%{old}" >&2; exit 1; }
echo "$old" > "$PRIV/q39.outdated"
nx "$CP" "apt-get update -qq $K8S_LIST >/dev/null 2>&1; apt-cache madison kubeadm | head -1" </dev/null | awk '{print $3}' > "$PRIV/q39.target"
[ -s "$PRIV/q39.target" ] && node_ready "$old" 60
""",
"checks": [
C("outdated.txt names the node with the old kubelet", 3, r"""
[ -s "$WS/q39/outdated.txt" ] && diff <(tr -d ' \r' < "$WS/q39/outdated.txt" | sed 's#.*/##' | grep . | sort -u) "$PRIV/q39.outdated" >/dev/null"""),
C("that node is cordoned and not drained; the others are schedulable", 3, r"""
old=$(cat "$PRIV/q39.outdated")
eq "$(j node "$old" '{.spec.unschedulable}')" true || exit 1
for n in "$CP" "$W1" "$W2"; do [ "$n" = "$old" ] || [ "$(j node "$n" '{.spec.unschedulable}')" != true ] || exit 1; done
eq "$(j node "$old" '{.status.nodeInfo.kubeletVersion}')" v%{old}"""),
C("target.txt is the newest kubeadm package version on offer", 2, r"""eq "$(tr -d ' \n\r' < "$WS/q39/target.txt" 2>/dev/null)" "$(cat "$PRIV/q39.target")" """),
C("plan.txt is the output of kubeadm upgrade plan", 2, r"""
grep -q 'kube-apiserver' "$WS/q39/plan.txt" 2>/dev/null && grep -qi 'upgrade' "$WS/q39/plan.txt" && grep -q 'kubelet' "$WS/q39/plan.txt" """),
],
"solution": """
k get nodes # the VERSION column is each node's kubelet
k get nodes -o custom-columns=NAME:.metadata.name,KUBELET:.status.nodeInfo.kubeletVersion
# -> the node on v%{old} goes into {WS}/q39/outdated.txt
k cordon <that node>
sudo apt-get update && apt-cache madison kubeadm | head -1 # third column -> {WS}/q39/target.txt
sudo kubeadm upgrade plan | tee {WS}/q39/plan.txt
# cordon only marks the node unschedulable: running Pods stay. drain = cordon + evict.
# `upgrade plan` reads the cluster and changes nothing: it lists the current and the target version
# of each component and which kubelets it found. The kubelet may be up to three minor versions
# behind the API server, never ahead - but an old release still carries its bugs.
""",
},
]