CertGrid CertGrid
Hands-on Lab·Kubernetes and Cloud Native Security Associate

The KCSA Practice Cluster and Its Defaults

Security behaviour depends on what is installed, so this guide reads the lab off the machines rather than describing it. All four clusters were rebuilt from scratch on the day this was written, which makes every number here a genuine default and a baseline to measure later changes against.

Orientation Guide 3 of 42 Beginner

Written against the versions above. A four-node kubeadm cluster rebuilt on 2026-08-25: apiserver v1.36.4, kubelets v1.36.3, containerd 2.2.6, Cilium 1.18.1 with Hubble. The kubelet is one patch BEHIND the apiserver, which is the supported direction; before the rebuild it was the reverse, which is not.

The cka5001 cluster: one control plane and 3 schedulable workers, on Cilium.
Server NameIP AddressOSRolesCPURAMHDD
CKA5001192.168.0.41Ubuntu 26.04 LTSControl Plane Node (tainted NoSchedule)2 Core4 GB50 GB
CKA5001-NODE01192.168.0.42Ubuntu 26.04 LTSWorker Node2 Core4 GB50 GB
CKA5001-NODE02192.168.0.43Ubuntu 26.04 LTSWorker Node2 Core4 GB50 GB
CKA5001-NODE03192.168.0.44Ubuntu 26.04 LTSWorker Node2 Core4 GB50 GB

Before you start

  1. Four nodes, rebuilt today, and which of them can run anything

    Node count is not the useful number. Taints are.

    bash Example session
    kubectl get nodes -o custom-columns='NAME:.metadata.name,STATUS:.status.conditions[-1].type,KUBELET:.status.nodeInfo.kubeletVersion,TAINTS:.spec.taints[*].key,AGE:.metadata.creationTimestamp'NAME             STATUS   KUBELET   TAINTS                                  AGEcka5001          Ready    v1.36.3   node-role.kubernetes.io/control-plane   2026-08-25T11:22:31Zcka5001-node01   Ready    v1.36.3   <none>                                  2026-08-25T11:23:22Zcka5001-node02   Ready    v1.36.3   <none>                                  2026-08-25T11:23:23Zcka5001-node03   Ready    v1.36.3   <none>                                  2026-08-25T11:23:51Z

    Expected resultFour Ready nodes on kubelet v1.36.3, every one created 2026-08-25. Only cka5001 carries node-role.kubernetes.io/control-plane; the three workers show <none>.

    Success conditionYou know how much of this cluster can actually schedule a Pod.

  2. A version skew, in the direction that is allowed

    Compare the apiserver with a kubelet.

    bash Example session
    echo "apiserver $(kubectl get --raw /version | python3 -c 'import json,sys;print(json.load(sys.stdin)["gitVersion"])')  kubelet $(kubectl get nodes -o jsonpath='{.items[0].status.nodeInfo.kubeletVersion}')"; echo "--- kubelet is one patch BEHIND the apiserver, which is the supported direction"apiserver v1.36.4  kubelet v1.36.3--- kubelet is one patch BEHIND the apiserver, which is the supported direction

    Expected resultapiserver v1.36.4 kubelet v1.36.3 - the kubelet one patch behind.

    Success conditionYou know which direction of skew is supported.

  3. The CNI, which decides what a policy actually does

    Every NetworkPolicy page on this path depends on this answer.

    bash Example session
    kubectl -n kube-system exec ds/cilium -c cilium-agent -- cilium version 2>/dev/null | head -2Client: 1.18.1 e8a7070f 2025-08-13T14:47:02+00:00 go version go1.24.6 linux/amd64Daemon: 1.18.1 e8a7070f 2025-08-13T14:47:02+00:00 go version go1.24.6 linux/amd64kubectl api-resources --api-group=cilium.io --no-headers 2>/dev/null | awk '{printf "  %-34s %s\n", $1, $2}' | head -5; echo "--- policy kinds that exist only because the CNI is Cilium"  ciliumcidrgroups                   ccg  ciliumclusterwidenetworkpolicies   ccnp  ciliumendpoints                    cep,ciliumep  ciliumidentities                   ciliumid  ciliuml2announcementpolicies       l2announcement--- policy kinds that exist only because the CNI is Cilium

    Expected resultCilium 1.18.1, client and daemon matching. Then Cilium's own API group: ciliumcidrgroups, ciliumclusterwidenetworkpolicies (ccnp), ciliumendpoints, ciliumidentities.

    Success conditionYou know what is enforcing policy here.

  4. What a default kubeadm cluster actually contains

    Five namespaces, and nothing you did not get from the installer.

    bash Example session
    kubectl get ns --no-headers -o custom-columns='N:.metadata.name' | tr '\n' ' '; echo; echo "--- five namespaces. Nothing has been installed on top"cilium-secrets default kube-node-lease kube-public kube-system--- five namespaces. Nothing has been installed on topkubectl get pods -A --no-headers | wc -l; echo "pods, all of them cluster infrastructure:"; kubectl get pods -A --no-headers -o custom-columns='NS:.metadata.namespace,N:.metadata.name' | sed 's/-[a-z0-9]*-[a-z0-9]*$//' | sort -u | head -1221pods, all of them cluster infrastructure:kube-system   ciliumkube-system   cilium-69l44kube-system   cilium-cgdjlkube-system   cilium-fntj7kube-system   cilium-j6wsjkube-system   cilium-operatorkube-system   corednskube-system   etcd-cka5001kube-system   hubble-relaykube-system   hubble-uikube-system   kubekube-system   kube-controllerkubectl get validatingwebhookconfigurations --no-headers 2>&1 | tail -1; kubectl get mutatingwebhookconfigurations --no-headers 2>&1 | tail -1; echo "--- no admission webhooks at all, so nothing is validating or mutating what you submit"No resources foundNo resources found--- no admission webhooks at all, so nothing is validating or mutating what you submit

    Expected resultcilium-secrets default kube-node-lease kube-public kube-system. 21 pods, all of them cilium, coredns, etcd, hubble, kube-apiserver, kube-controller-manager, kube-proxy and kube-scheduler. And No resources found for both webhook kinds.

    Success conditionYou know exactly what is running before you add anything.

  5. The posture it starts in, measured

    Three questions any security review asks first. Nothing here is assumed.

    bash Example session
    kubectl get ns -o custom-columns='NAME:.metadata.name,PSA-ENFORCE:.metadata.labels.pod-security\.kubernetes\.io/enforce' --no-headers | awk '$2=="<none>"{n++} END {print n" of 5 namespaces have NO Pod Security Admission enforce label"}'5 of 5 namespaces have NO Pod Security Admission enforce labelkubectl get networkpolicy -A 2>&1 | tail -1; kubectl get cnp,ccnp -A 2>&1 | tail -1; echo "--- no policy of either kind, so every Pod can reach every other Pod"No resources foundNo resources found--- no policy of either kind, so every Pod can reach every other Podkubectl get clusterrolebinding -o json | python3 -c "import json,sysd=json.load(sys.stdin)subs=[(b['metadata']['name'], s.get('kind'), s.get('name')) for b in d['items'] if b['roleRef']['name']=='cluster-admin' for s in (b.get('subjects') or [])]for b,k,n in subs: print('  %-28s %-8s %s' % (b,k,n))print('%d subject(s) hold cluster-admin - both of them kubeadm own' % len(subs))"  cluster-admin                Group    system:masters  kubeadm:cluster-admins       Group    kubeadm:cluster-admins2 subject(s) hold cluster-admin - both of them kubeadm own

    Expected result5 of 5 namespaces have no PSA enforce label. No resources found for both NetworkPolicy and CiliumNetworkPolicy. And 2 subjects hold cluster-admin: system:masters and kubeadm:cluster-admins.

    Success conditionYou have a baseline to measure every later change against.

  6. The supply chain, when nobody has added to it yet

    Twelve images. Count how many are pinned.

    bash Example session
    kubectl get pods -A -o go-template='{{range .items}}{{range .spec.containers}}{{.image}}{{"\n"}}{{end}}{{end}}' | sort -u | wc -l; echo "distinct images"; kubectl get pods -A -o go-template='{{range .items}}{{range .spec.containers}}{{.image}}{{"\n"}}{{end}}{{end}}' | grep -c '@sha256:'; echo "pinned by digest"; kubectl get pods -A -o go-template='{{range .items}}{{range .spec.containers}}{{.image}}{{"\n"}}{{end}}{{end}}' | grep -oE '^[^/]+' | sort | uniq -c | sort -rn12distinct images12pinned by digest     12 quay.io     10 registry.k8s.io

    Expected result12 distinct images, 12 pinned by digest, from quay.io (12 containers) and registry.k8s.io (10).

    Success conditionYou know what a fully pinned cluster looks like, and why.

  7. One Pod, and everything it is handed without asking

    The defaults, made visible. Then cleaned up.

    bash Example session
    kubectl run kcsa-probe --image=docker.io/library/busybox:1.37 --restart=Never --command -- sh -c 'sleep 600' >/dev/null && kubectl wait --for=condition=Ready pod/kcsa-probe --timeout=120s && kubectl get pod kcsa-probe -o custom-columns='NAME:.metadata.name,NODE:.spec.nodeName,SA:.spec.serviceAccountName,RUNASUSER:.spec.securityContext.runAsUser'pod/kcsa-probe condition metNAME         NODE             SA        RUNASUSERkcsa-probe   cka5001-node02   default   <none>kubectl exec kcsa-probe -- id; kubectl exec kcsa-probe -- sh -c 'grep -E "^CapEff" /proc/self/status'; echo "--- root, and the capability set containerd hands every container"uid=0(root) gid=0(root) groups=0(root),10(wheel)CapEff:	00000000a80425fb--- root, and the capability set containerd hands every containerkubectl exec kcsa-probe -- ls /var/run/secrets/kubernetes.io/serviceaccount/; kubectl exec kcsa-probe -- sh -c 'touch /newfile && echo "root filesystem is WRITABLE"'ca.crtnamespacetokenroot filesystem is WRITABLEkubectl exec kcsa-probe -- sh -c 'wget -q -O- --no-check-certificate --timeout=5 --header="Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" https://kubernetes.default.svc/api/v1/namespaces/default/pods 2>&1 | head -c 180'; echo; echo "--- 403. The token is real and reaches the apiserver; RBAC is what refuses it"wget: server returned error: HTTP/1.1 403 Forbidden --- 403. The token is real and reaches the apiserver; RBAC is what refuses itkubectl auth can-i --list --as=system:serviceaccount:default:default --no-headers 2>/dev/null | wc -l; echo "rules the default ServiceAccount holds"23rules the default ServiceAccount holdskubectl delete pod kcsa-probe --wait=true 2>&1 | tail -1; kubectl get pod kcsa-probe 2>&1 | tail -1pod "kcsa-probe" deleted from default namespaceError from server (NotFound): pods "kcsa-probe" not found

    Expected resultScheduled to cka5001-node02, SA default, RUNASUSER <none>. Inside: uid=0(root), CapEff: 00000000a80425fb, the token mounted, and root filesystem is WRITABLE. The token reaches the apiserver and gets 403. The default SA holds 23 rules.

    Success conditionYou have seen what every Pod gets by default.

Troubleshooting

Official sources