CKS System Hardening — the host that is not the cluster
Load the profile on the worker. Point the Pod at it. RuntimeDefault is the fleet. Localhost is the exam.
<!-- hal:authoritative:yaml -->
Load the profile on the worker. Point the Pod at it. RuntimeDefault is the fleet. Localhost is the exam.
§I — Frame
k8s_day_counter reads 13. Odd is CKS. 08-25 closed Cluster Hardening 15% on Q04 and wrote the leftover in its pairing section: System Hardening 15% remains. That fire is this file.
CKS first pass on the Cert slot of this sprint now has a home for every blueprint domain that the counter opened: Setup 08-01, Hardening 08-25, Microservices store 08-19 plus PSS 07-26, Supply Chain 08-07, Runtime 08-13, and today the host. Official current weight for this domain is 15%. The Bootcamp Q01 and Q33 headers still say 10%. The cks-certification-guide TOC still says 10%. Same currency class as Fajr #86's Entra rename: the clone is usable, the percentage in the header is stale. Grade the stems. Do not quote 10% as the sit weight.
08-13 already spent gVisor RuntimeClass (Q10/Q22). Rice puts gVisor after AppArmor in Ch. 8. Do not spend this clock on runsc. 08-01 already spent kube-bench. Host OS footprint on the exam is not a CIS rerun. It is apparmor_parser plus a JSON file under the kubelet seccomp directory.
The day's Ops lesson is GKE: COS_CONTAINERD, Shielded VM wrap, AppArmor enabled on describe node. Poulton already told you a hosted cluster hides the apiserver static Pod. It also hides the worker you would SSH. The exam is not hosted. The exam is kubeadm. This file SSHes to the worker, loads a profile, and edits a Pod. The fleet cannot do the first half on Autopilot. The exam cannot do the COS half. Learn both. Grade this file on aa-status and the JSON path.
Coin it: the host that is not the cluster.
§II — Domain foundations
System Hardening, four official bullets.
- Minimize host OS footprint (reduce attack surface).
- Using least-privilege identity and access management.
- Minimize external access to the network.
- Appropriately use kernel hardening tools such as AppArmor, seccomp.
Q01 and Q33 spend bullet 4 in two stems. Bullet 1 is the Ubuntu-versus-COS choice Ops already named, and kubestronaut-hedron's systemctl/apt purge tasks. Bullet 2 on this domain is users and chmod on the box, not GCP IAM, not RBAC (RBAC was 08-25). Bullet 3 is ss and ufw. A pass that only loads AppArmor and leaves a world-writable kubelet dir is a partial pass. A pass that only ufw's 6443 and leaves Unconfined nginx is a fail on the stem you actually got.
Rice's order is the picture to hold (Ch. 8, printed pp. 95-99). seccomp filters syscalls. The 2005 mode allowed four: sigreturn, exit, read, write on already-open fds. Too small to work. seccomp-bpf (2012) filters by opcode and arguments. Docker's default blocks clock_settime, create_module, keyctl. AppArmor is an LSM that filters files and capabilities. Look in /sys/module/apparmor/parameters/enabled. y means the LSM is on. Complain logs. Enforce denies. Kubernetes does not apply Docker's default AppArmor profile unless you ask (p. 98). Missing YAML is Unconfined by policy, even when kubectl describe node says AppArmor enabled.
Bullet 1 (footprint) is not a mystery. Disable services you do not need. Purge packages. COS does this by being small. Ubuntu on the exam does not. If a stem names a service to stop, systemctl disable --now that service on the worker. Do not stop kubelet. Do not stop containerd. Bullet 3 is ss -tuln then ufw or iptables. Allow SSH so you can still parser. Allow 6443 if this node is the control plane, which it should not be for Q01. Bullet 2 is chmod 700 on kubelet dirs, not a Role. 08-25 already spent Role. If you create a Role in this file you are in the wrong domain.
Muschko's companion repo files the domain as ch04/apparmor and ch04/seccomp. The apparmor Pod still uses the beta annotation (container.apparmor.security.beta.kubernetes.io/hello: localhost/k8s-deny-write). Q01 SolutionNotes tells you that on 1.30+ you should prefer securityContext.appArmorProfile. Verify.bash accepts both. Write the new field. Keep the annotation as the fallback you can still grep.
§III — Q01 flavor: AppArmor on the worker
LabSetUp does three things you must not redo. It finds the worker (grep -v control-plane). It writes /etc/apparmor.d/nginx_apparmor on that worker with profile name nginx-profile-2, file allow, deny writes to /etc, /root, /home. It writes /home/candidate/nginx-pod.yaml with nodeName pinned to that worker and no AppArmor field.
SolutionNotes is three steps.
- SSH to the worker.
apparmor_parser -q /etc/apparmor.d/nginx_apparmor.aa-status | grep nginx-profile-2. Exit. - Edit the Pod. On 1.30+ (the lab comment says v1.35):
securityContext:
appArmorProfile:
type: Localhost
localhostProfile: nginx-profile-2
That block lives under the container, not under spec, in Q01's verify jsonpath (.spec.containers[0].securityContext.appArmorProfile.localhostProfile). Put it on the Pod spec and Verify.bash misses it unless you also set the annotation.
- Apply.
kubectl exec nginx-pod -- touch /etc/testmust fail.
Verify.bash's five checks, in order: profile loaded on the worker, Pod Running, Pod on that worker, profile referenced (new field or annotation localhost/nginx-profile-2), write to /etc denied. A Running Pod on the right node with the YAML comment # nginx-profile-2 fails check 4. A profile loaded on the control plane fails check 1. A profile loaded on node01 with the Pod on node02 fails check 1 or 3 depending on luck. LabSetUp pinned nodeName so luck is not the exam.
If/then: if aa-status is quiet, parser did not run or ran on the wrong node. If the Pod is CreateContainerError, the kubelet cannot find the profile on this node. If exec touch succeeds, the profile is not enforcing on that container. Complain mode would log and allow. The planted profile is enforce. You should see deny.
Do not copy the profile to the control plane "for safety." The kubelet that starts the container is the worker kubelet. Rice: install under /etc/apparmor and run apparmor_parser (p. 98). That path is the worker's path.
§IV — Q33 flavor: seccomp JSON on the kubelet dir
LabSetUp creates namespace secure-app, mkdir /var/lib/kubelet/seccomp/profiles (no file), and a Deployment webapp with nginx:1.25 and no seccomp.
The stem wants a custom profile at /var/lib/kubelet/seccomp/profiles/seccomp-profile.json that allows only read, write, exit, sigreturn. defaultAction SCMP_ACT_ERRNO. Then the Deployment must use securityContext.seccompProfile type Localhost, localhostProfile profiles/seccomp-profile.json.
SolutionNotes writes the JSON with architectures X86_64, X86, X32. The relative path is the whole point: localhostProfile is relative to /var/lib/kubelet/seccomp/. If you put the full absolute path in the YAML, Verify.bash fails check 5. If you put seccomp-profile.json without profiles/, it fails check 5. If you put the JSON under /tmp and point at it, the kubelet never sees it.
Verify.bash checks: file exists, SCMP_ACT_ERRNO present, each of the four syscall names present, Deployment type Localhost at pod-level or container-level, localhostProfile exact string profiles/seccomp-profile.json.
Rice's original seccomp (p. 96) is those four syscalls. The exam is asking you to rebuild 2005 seccomp as a Localhost profile. RuntimeDefault is the Docker-default-class filter K8sUR3 Ch. 14 turns on with one type field. Q33 does not want RuntimeDefault. Q33 wants the file you wrote. A candidate who sets RuntimeDefault because yesterday's PSA lesson said so will fail the file checks and the path check.
If/then: if the file has SCMP_ACT_ALLOW as default and a block list, you inverted the stem. The stem is default deny, allow four. If the Deployment rolls and the new Pod is CrashLoop, the profile is too tight for nginx (nginx needs more than four syscalls). The exam verify does not require the Pod to be Ready. It greps YAML and the file. Still: if you can keep it running with a slightly larger allow list, the stem did not give you that list. Write the four. Let verify pass. Do not debug nginx in the last ten minutes.
§V — Worked example: the two leftover mistakes
Mistake one. You load nginx-profile-2, you set the annotation on metadata to localhost/nginx-profile-2, you forget nodeName matches. LabSetUp already set nodeName. You "cleaned" it because a pin felt like a hack. The scheduler puts the Pod on the control plane. aa-status on the worker is green. The Pod is Unconfined. Verify check 3 fails. Put the pin back.
Mistake two. You write the seccomp JSON correctly. You edit the live Pod from Q01 muscle memory. Q33's object is a Deployment. Verify greps deployment webapp. A patched Pod is owned by a ReplicaSet that will resurrect Unconfined. kubectl edit deployment webapp -n secure-app and put seccompProfile on spec.template.spec.securityContext (pod-level) so every container inherits. Container-level also passes verify. Template-level is the one that survives a rollout. 08-28 already taught you the template is what rolls.
Host OS leftover, named not drilled: disable unused services on the worker (systemctl list-units), close extra ports (ss -tuln), ufw default deny plus 6443. kubestronaut-hedron writes those as tasks. If a stem asks you to stop a service, stop that service. Do not invent a kube-bench run. 08-01 owns kube-bench.
The planted AppArmor profile is worth reading once so you do not fight it. LabSetUp includes tunables/global, flags attach_disconnected, abstractions/base, then file, (allow reads), write allow on nginx pid and cache and /tmp, deny write on /etc /root /home. A candidate who rewrites the profile to deny /tmp will break nginx and waste the clock. The verify is touch /etc. Leave /tmp.
Muschko's k8s-deny-write is stricter: deny /** w. That profile plus a Pod that needs to write anything will sit in crash. Q01 did not plant that profile. Do not paste Muschko over LabSetUp. The companion is the shape. The planted file is the exam.
08-28's Cert file was CKA App Lifecycle. A rolling update does not load AppArmor. maxSurge is extra Pods, not extra syscalls. If a stem says "remain available (2 replicas)" you are in last Friday. If a stem says "file writes should be denied" you are here.
08-19's encryptionConfig is etcd. System Hardening does not edit EncryptionConfiguration. If you open that file today you are rereading the store half.
Kernel modules: Rice names create_module, delete_module, init_module as calls Docker's default seccomp already blocks (p. 96). A stem that says "prevent loading kernel modules" can be a seccomp profile, or it can be a host sysctl, or it can be COS which already dropped that surface. On the exam, prefer the object the stem named. If it named a seccomp file, write the file. If it named a module, blacklist the module on the worker. Do not do both and hope.
Q12 docker.sock is hostPath. Adjacent to footprint. Not today's stem. If it shows up as a second question, unmount the socket, do not spend AppArmor on it first.
§VI — Connection to today's Ops + Dev
Ops is GKE Standard, COS, Shielded, describe node AppArmor enabled, Unconfined Deployment. That is the fleet leftover this exam stem manufactures by hand. Autopilot deletes the SSH. COS already loaded a profile. The exam worker is Ubuntu so you can parser. Do not change GKE image_type to Ubuntu to make Q01 feel like production. That grows the footprint bullet 1 told you to shrink.
Dev is the Python census. It prints the jsonpaths Verify.bash greps. It does not patch. Q01 is one Pod. A loop that patches every absent AppArmor is a fleet event. The Role on that tool is get,list pods. The Role on the exam is whatever kubeconfig you were given, plus SSH.
GKE Shielded bits do not appear in kubectl. enable_secure_boot is google-plugin / gcloud. The exam cluster has no google plugin. Do not spend minutes looking for shieldedInstanceConfig in Pod YAML. It will not be there. The host fields you can see from kubectl are node conditions (AppArmor enabled), Pod securityContext, and annotations. Everything else is SSH or gcloud. Pick the tool the stem implies.
§VII — Practice questions
aa-status on the control plane has no nginx-profile-2. The Pod yaml has no securityContext. What is the first command, and why is a running kubectl get pods not a pass?metadata.annotations.container.apparmor.security.beta.kubernetes.io/nginx-pod: nginx-profile-2 (no localhost/ prefix). Verify check 4 fails. Why?touch /etc/test is denied. touch /tmp/test succeeds. Is the profile broken?/var/lib/kubelet/seccomp/profiles/seccomp-profile.json. File is in the right place. Type is Localhost. Verify check 5 fails. Why?§VIII — Close
Four bullets, one leftover domain now closed on this Cert-slot first pass. Q01 is parser plus a Pod field. Q33 is a JSON file plus a relative path. GKE COS is the hosted form of the same host, and it will not let you SSH on Autopilot.
Wait for the static Pod of kubelet? There is no static Pod for this domain. AppArmor is a kernel file. seccomp Localhost is a kubelet directory file. Neither restarts the API. If you wait two minutes for an apiserver bounce you copied 08-25's wait into the wrong hour. Save that wait for Q04.
Examine well. AppArmor enabled on the node is not a Pod profile. A missing localhostProfile is Unconfined. Both are the host that is not the cluster.
Related
- Prior arc: CKS Cluster Hardening (2026-08-25)
- Domain hub: Cross-References/Certifications-Roadmap
- Grounding tome: Rice Ch.8, Seccomp and AppArmor, pp. 95-99