The CKS (Certified Kubernetes Security Specialist) is the third and most demanding of the CNCF's Kubernetes certifications. It certifies that you know how to harden a cluster, detect what is happening inside it and respond when something goes wrong. It is the certification for the profile that, at Rutas Norte, makes sure bookings-postgres —which holds travellers' personal data— is encrypted, isolated, audited and monitored.

It has one peculiarity that sets it apart from the other two exams: to sit it you need a valid CKA. That is not a recommendation, it is an administrative requirement. And there is a reason: the CKS assumes you already know how to administer the cluster and devotes itself exclusively to securing it, with external tools that do not appear in the other exams.

Everything in this lesson takes a strictly defensive angle: harden, detect and respond. No offensive techniques are taught or described.

Essential notice. Price, duration, number of tasks, pass mark, Kubernetes version assessed and domain weighting change over time. What follows is indicative. Always check the current official curriculum on the Linux Foundation / CNCF website (training.linuxfoundation.org, cncf.io/certification/cks) before you enrol.

Security warning. The configurations shown here are study material aimed at an exam. Before applying any security policy to a real environment, it must be reviewed by a security professional who knows the context, the threat model and the organisation's regulatory requirements.

Contents

  1. What the CKS certifies and the valid-CKA requirement
  2. The exam format and why it is the most demanding
  3. The curriculum domains and their indicative weights
  4. Full map: every CKS objective and its lesson in this course
  5. The tools the CKS requires you to handle
  6. Procedures you must execute without hesitating
  7. Study plan and exam traps
  8. Eight CKS-style tasks solved

  1. What the CKS certifies and the valid-CKA requirement

1.1 The profile

The CKS attests to competence in securing container-based applications and platforms throughout their whole lifecycle: build, deployment and runtime. In Rutas Norte terms:

Phase What is certified Example
Build That the images entering the cluster are clean and verifiable Scanning rutas-norte/bookings-api:2.4 with Trivy and signing it before publishing
Deployment That whatever is admitted into the cluster meets a policy Preventing a privileged pod from reaching rutas-norte-pro
Runtime That anomalies are detected and responded to Detecting a shell opened inside a production container and isolating the pod
Platform That the cluster itself is hardened Encrypting Secrets in etcd, auditing the API, restricting RBAC

1.2 The entry requirement

You must hold a valid (not expired) CKA at the time you sit the CKS. Practical consequences:

  • You cannot take the CKS first. The order is compulsory: CKA → CKS.
  • If your CKA expires (validity is around two years) before you take the CKS exam, you will not be able to sit it until you renew.
  • The CKS certification, once obtained, has its own independent validity.
  • Check the official handbook for how this requirement is verified and how far in advance.

Planning tip: do not let too much time pass between the CKA and the CKS. Beyond the expiry risk, the CKS reuses a lot from the CKA (RBAC, kubeadm, static pods, diagnosis) and it is a shame to lose that form.

1.3 How it differs from the CKA and the CKAD

Aspect CKA CKAD CKS
Central question Is the cluster working? Is my application working? Is any of this secure?
External tools None None kube-bench, Trivy, Falco, AppArmor, seccomp, Kyverno/OPA, gVisor
Documentation allowed kubernetes.io kubernetes.io kubernetes.io + the Trivy, Falco and AppArmor docs
Prerequisite None None Valid CKA
Indicative no. of tasks 15-20 15-20 15-16 (fewer, but longer)
Perceived difficulty Medium-high Medium High

  1. The exam format and why it is the most demanding

2.1 Characteristics

Aspect Indicative description
Type 100 % hands-on, browser-based terminal, proctored
Duration Around two hours
Number of tasks Approximately 15-16
Pass mark Around 67 %
Clusters Several, with a context switch per task
Scoring Per task, with partial marks
Documentation allowed kubernetes.io/docs, plus the official Trivy, Falco and AppArmor websites
Prerequisite Valid CKA
Validity Around two years

Indicative. Verify it on the official website, including the exact list of allowed documentation domains, which has varied between programme revisions.

2.2 Why it is considered the hardest

Four concrete reasons:

  1. Tools outside Kubernetes. You have to know how to invoke kube-bench, read its output and fix what it flags; run trivy image and interpret severities; write a Falco rule and know where its configuration file lives. None of that is in kubectl.
  2. Long tasks. With ~15 tasks in 120 minutes that is ~8 minutes on average, but a single task may ask you to edit the apiserver manifest, restart the control plane and wait for it to come back. Dead time counts.
  3. Work on the node, not just against the API. AppArmor, seccomp, audit-policy, etcd encryption and kube-bench are configured with ssh and sudo over system files.
  4. Failure is catastrophic if you get it wrong. A mistake in /etc/kubernetes/manifests/kube-apiserver.yaml leaves the API down and blocks the remaining tasks on that cluster. You have to know how to recover.

2.3 The rule that saves the exam: back up before you touch

Before editing any critical control plane file:

sudo cp /etc/kubernetes/manifests/kube-apiserver.yaml /root/kube-apiserver.yaml.bak

If the API does not come back after the change, you restore and try again:

sudo cp /root/kube-apiserver.yaml.bak /etc/kubernetes/manifests/kube-apiserver.yaml

Be careful where you keep the backup. Never inside /etc/kubernetes/manifests/: the kubelet would try to start the .bak file as an extra static pod and you would have two apiservers fighting over the port. Keep it in /root/ or /tmp/.

2.4 How to check that the apiserver is back

# The control plane takes between 20 and 60 seconds to recover
sudo crictl ps | grep kube-apiserver
kubectl get nodes

If kubectl answers connection refused, look at the container logs:

sudo crictl ps -a | grep apiserver
sudo crictl logs <container-id> 2>&1 | tail -20

  1. The curriculum domains and their indicative weights

Split published at the time of writing; verify it on the official website.

Domain Indicative weight What it covers
Cluster Setup ~15 % NetworkPolicies, CIS benchmark, Ingress with TLS, node access, binary verification
Cluster Hardening ~15 % Minimal RBAC, ServiceAccounts, restricting API access, frequent upgrades
System Hardening ~10 % Minimising the OS surface, IAM, restricting network access, AppArmor, seccomp
Minimise microservice vulnerabilities ~20 % Security contexts, OS-level security domains, Secrets, sandboxing (gVisor), mTLS
Supply Chain security ~20 % Minimal base images, allowed registry lists, signing and validation, static analysis, scanning
Monitoring, logging and runtime security ~20 % Behavioural analysis, threat detection, immutability, audit logs

The three heaviest domains (microservices, supply chain and runtime, ~60 % between them) are precisely the ones you worked through in lessons 08-02, 08-05 and 08-06.


  1. Full map: every CKS objective and its lesson in this course

4.1 Cluster Setup (~15 %)

Official objective Course lesson
Use network-level security (NetworkPolicies) 04-06-network-policies, 08-04-network-security
Use the CIS benchmark to review component configuration 08-06-auditing-scanning-and-vulnerabilities
Configure Ingress objects correctly with TLS security 04-04-ingress-controllers, 04-05-tls-and-certificates-with-cert-manager
Protect node metadata and endpoints 08-04-network-security
Minimise the use of and access to GUI elements 08-01-role-based-access-control
Verify platform binaries before deployment 08-05-image-security

4.2 Cluster Hardening (~15 %)

Official objective Course lesson
Restrict access to the Kubernetes API 08-01-role-based-access-control, 03-06-serviceaccounts-and-api-access
Use RBAC to minimise exposure 08-01-role-based-access-control
Be careful with ServiceAccounts (disable the default one, tokens) 03-06-serviceaccounts-and-api-access
Upgrade Kubernetes frequently 10-02-kubeadm

4.3 System Hardening (~10 %)

Official objective Course lesson
Minimise the host operating system footprint 08-02-security-contexts-and-hardening
Minimise IAM roles 08-01-role-based-access-control, 10-06-managed-kubernetes-eks-aks-gke
Minimise external network access 04-06-network-policies, 08-04-network-security
Use kernel hardening tools: AppArmor, seccomp 08-02-security-contexts-and-hardening, 08-03-pod-security-policies-and-standards

4.4 Minimise microservice vulnerabilities (~20 %)

Official objective Course lesson
Apply OS-level security domains (PSA, securityContext) 08-02-security-contexts-and-hardening, 08-03-pod-security-policies-and-standards
Manage Kubernetes Secrets 03-02-secrets, 08-06-auditing-scanning-and-vulnerabilities
Use sandboxed runtimes (gVisor, kata) 08-02-security-contexts-and-hardening
Implement pod-to-pod encryption with mTLS 08-04-network-security

4.5 Supply Chain security (~20 %)

Official objective Course lesson
Minimise the base image footprint 08-05-image-security
Restrict the allowed registries 08-05-image-security, 08-03-pod-security-policies-and-standards
Sign and validate images (Cosign) 08-05-image-security
Use static analysis of workloads 08-06-auditing-scanning-and-vulnerabilities, 11-03-cicd-with-kubernetes
Scan images for known vulnerabilities 08-06-auditing-scanning-and-vulnerabilities

4.6 Monitoring, logging and runtime security (~20 %)

Official objective Course lesson
Behavioural analysis at syscall and process level 08-06-auditing-scanning-and-vulnerabilities (Falco)
Detect threats in infrastructure, apps, networks and storage 08-06-auditing-scanning-and-vulnerabilities, 07-04-visualization-and-alerting-with-grafana
Detect every phase of an attack, wherever it occurs 08-06-auditing-scanning-and-vulnerabilities, 11-06-production-operations
Perform deep forensic analysis of suspicious activity 07-06-debugging-and-cluster-events, 08-06-auditing-scanning-and-vulnerabilities
Ensure the immutability of running containers 08-02-security-contexts-and-hardening
Use audit logs to monitor access 08-06-auditing-scanning-and-vulnerabilities

  1. The tools the CKS requires you to handle

These nine pieces appeared in module 8. Here we revisit them exam-oriented: the exact command, the exact file and the exact check.

5.1 kube-bench: the CIS standard

kube-bench checks the cluster configuration against the CIS Kubernetes Benchmark and tells you what is wrong and how to fix it.

# Run it against the control node
kube-bench run --targets=master

# Against a worker node
kube-bench run --targets=node

# A single specific control
kube-bench run --targets=master --check=1.2.20

Typical output:

[INFO] 1 Control Plane Security Configuration
[INFO] 1.2 API Server
[FAIL] 1.2.20 Ensure that the --profiling argument is set to false (Automated)
[PASS] 1.2.21 Ensure that the --audit-log-path argument is set (Automated)

== Remediations master ==
1.2.20 Edit the API server pod specification file
/etc/kubernetes/manifests/kube-apiserver.yaml
on the control plane node and set the below parameter.
--profiling=false

What you have to know how to do: read the [FAIL], go to the == Remediations == section, apply exactly what it says in the file it names, and verify by running kube-bench again with --check.

The most common FAILs and their fixes:

Control Fix in /etc/kubernetes/manifests/kube-apiserver.yaml
--profiling - --profiling=false
--anonymous-auth - --anonymous-auth=false
--authorization-mode - --authorization-mode=Node,RBAC (never AlwaysAllow)
--audit-log-path - --audit-log-path=/var/log/kubernetes/audit.log
--kubelet-certificate-authority Point it at the cluster CA
--insecure-port No longer exists in modern versions; if it shows up, remove it

And on the kubelet (/var/lib/kubelet/config.yaml):

authentication:
  anonymous:
    enabled: false          # never true
  webhook:
    enabled: true
authorization:
  mode: Webhook             # never AlwaysAllow
readOnlyPort: 0             # close port 10255
protectKernelDefaults: true

After editing it: sudo systemctl restart kubelet.

5.2 Trivy: image scanning

# Scan an image
trivy image rutas-norte/bookings-api:2.4

# Only critical and high vulnerabilities
trivy image --severity CRITICAL,HIGH rutas-norte/bookings-api:2.4

# Only the ones with a patch available
trivy image --ignore-unfixed --severity CRITICAL nginx:1.27-alpine

# Compact output, ideal for the exam
trivy image --severity CRITICAL --format table --quiet postgres:16

# Scan Kubernetes manifests (static analysis)
trivy config /path/to/manifests/

# Scan a whole cluster
trivy k8s --report summary cluster

Output:

rutas-norte/bookings-api:2.4 (alpine 3.20)
==========================================
Total: 3 (CRITICAL: 3)

┌────────────┬────────────────┬──────────┬───────────────────┬───────────────┐
│  Library   │ Vulnerability  │ Severity │ Installed Version │ Fixed Version │
├────────────┼────────────────┼──────────┼───────────────────┼───────────────┤
│ openssl    │ CVE-2024-XXXXX │ CRITICAL │ 3.1.4-r5          │ 3.1.6-r0      │
└────────────┴────────────────┴──────────┴───────────────────┴───────────────┘

Typical exam task: "out of these four pods, delete the ones using images with CRITICAL vulnerabilities". Procedure:

# 1. Pull out the images of the pods in the namespace
kubectl get pods -n rutas-norte-pro \
  -o custom-columns='POD:.metadata.name,IMAGE:.spec.containers[*].image'

# 2. Scan each one
for img in nginx:1.27-alpine postgres:16 redis:7-alpine node:20-alpine; do
  echo "=== $img ==="
  trivy image --severity CRITICAL --quiet "$img" | grep -c CVE || true
done

# 3. Delete the ones that apply
kubectl delete pod <name> -n rutas-norte-pro

5.3 Falco: runtime detection

Falco watches system calls and fires alerts when behaviour matches a rule.

File Contents
/etc/falco/falco.yaml General configuration: outputs, format, rule files loaded
/etc/falco/falco_rules.yaml Default rules (do not edit)
/etc/falco/falco_rules.local.yaml Your rules and overrides
/etc/falco/rules.d/ Directory for additional rules
# Status and logs
sudo systemctl status falco
sudo journalctl -u falco -f
sudo journalctl -u falco --since "10 minutes ago" | grep Warning

# Reload after changing rules
sudo systemctl restart falco

# Validate rule syntax without starting
falco --validate /etc/falco/falco_rules.local.yaml

A typical output:

09:14:22.113 Notice A shell was spawned in a container with an attached terminal
  (user=root container_id=a1b2c3d4 container_name=bookings-api
   shell=sh parent=runc cmdline=sh)

Changing the output format of an existing rule (a very frequent task): you override it in falco_rules.local.yaml.

# /etc/falco/falco_rules.local.yaml
- rule: Terminal shell in container
  output: >
    %evt.time,%user.name,%container.id,%container.name,%proc.name
  override:
    output: replace

Writing a new rule:

- rule: Suspicious write to /etc inside a container
  desc: Detects writes to /etc inside a container
  condition: >
    container and
    evt.type in (open, openat) and
    evt.is_open_write=true and
    fd.name startswith /etc
  output: >
    Write to /etc detected (user=%user.name container=%container.name
    file=%fd.name command=%proc.cmdline)
  priority: WARNING
  tags: [filesystem, container]

And saving the output wherever the statement asks:

sudo journalctl -u falco --no-pager | grep "Terminal shell" > /opt/incidents.log

5.4 AppArmor applied to a pod

The full flow, which you have to know by heart:

# 1. On the NODE where the pod will run: create the profile
sudo tee /etc/apparmor.d/k8s-rutas-norte-deny-write <<'EOF'
#include <tunables/global>

profile k8s-rutas-norte-deny-write flags=(attach_disconnected) {
  #include <abstractions/base>

  file,
  deny /** w,          # deny all writes
}
EOF

# 2. Load it in enforce mode
sudo apparmor_parser -q /etc/apparmor.d/k8s-rutas-norte-deny-write

# 3. Check that it is loaded
sudo aa-status | grep rutas-norte
   k8s-rutas-norte-deny-write

4. Apply it to the pod. Since Kubernetes 1.30 there is a native field in securityContext (before that, only the annotation existed):

apiVersion: v1
kind: Pod
metadata:
  name: notifications-worker
  namespace: rutas-norte-pro
spec:
  containers:
  - name: worker
    image: busybox:1.36
    command: ["sleep", "3600"]
    securityContext:
      appArmorProfile:
        type: Localhost
        localhostProfile: k8s-rutas-norte-deny-write

The old form, still accepted and the one that appears in many statements:

metadata:
  annotations:
    container.apparmor.security.beta.kubernetes.io/worker: localhost/k8s-rutas-norte-deny-write

Check:

kubectl exec notifications-worker -n rutas-norte-pro -- touch /tmp/test
# touch: /tmp/test: Permission denied

Traps: the profile must be loaded on the node where the pod is scheduled (use nodeName or labels if the cluster has several); the name in localhostProfile is the profile name, not the file path; type: RuntimeDefault applies the runtime's default profile and type: Unconfined disables AppArmor.

5.5 seccomp applied to a pod

apiVersion: v1
kind: Pod
metadata:
  name: bookings-api-seccomp
  namespace: rutas-norte-pro
spec:
  securityContext:
    seccompProfile:
      type: RuntimeDefault          # the runtime's default profile: the most common one
  containers:
  - name: api
    image: nginx:1.27-alpine

With a custom profile, the file must live in the node's /var/lib/kubelet/seccomp/:

sudo mkdir -p /var/lib/kubelet/seccomp/profiles
sudo tee /var/lib/kubelet/seccomp/profiles/audit.json <<'EOF'
{
  "defaultAction": "SCMP_ACT_LOG"
}
EOF
    securityContext:
      seccompProfile:
        type: Localhost
        localhostProfile: profiles/audit.json      # path RELATIVE to /var/lib/kubelet/seccomp/
type Meaning
RuntimeDefault The runtime's default profile (blocks dangerous syscalls). The right answer 80 % of the time.
Localhost Custom profile at /var/lib/kubelet/seccomp/<localhostProfile>
Unconfined No restriction. Never the answer they are looking for.

Classic trap: putting the absolute path in localhostProfile. It is relative to the kubelet's seccomp directory.

5.6 The apiserver audit policy

This is the long task par excellence. Two parts: writing the policy and wiring it into the apiserver.

Part 1 — the policy, in /etc/kubernetes/audit/policy.yaml:

apiVersion: audit.k8s.io/v1
kind: Policy
omitStages:
  - RequestReceived
rules:
# Do not log noisy read-only requests
- level: None
  verbs: ["get", "list", "watch"]
  resources:
  - group: ""
    resources: ["events"]

# Secrets and ConfigMaps: metadata only, never the content
- level: Metadata
  resources:
  - group: ""
    resources: ["secrets", "configmaps"]

# Pods in the critical namespace: full request and response bodies
- level: RequestResponse
  namespaces: ["rutas-norte-pro"]
  resources:
  - group: ""
    resources: ["pods"]

# Everything else, at metadata level
- level: Metadata

The four levels, which you have to tell apart:

Level What gets logged
None Nothing
Metadata Who, what, when, on which resource. No bodies.
Request Metadata + request body
RequestResponse Metadata + request and response bodies

Part 2 — wiring it into the apiserver, in /etc/kubernetes/manifests/kube-apiserver.yaml:

spec:
  containers:
  - command:
    - kube-apiserver
    - --audit-policy-file=/etc/kubernetes/audit/policy.yaml
    - --audit-log-path=/var/log/kubernetes/audit/audit.log
    - --audit-log-maxage=30
    - --audit-log-maxbackup=10
    - --audit-log-maxsize=100
    volumeMounts:
    - mountPath: /etc/kubernetes/audit
      name: audit-policy
      readOnly: true
    - mountPath: /var/log/kubernetes/audit
      name: audit-log
      readOnly: false
  volumes:
  - name: audit-policy
    hostPath:
      path: /etc/kubernetes/audit
      type: DirectoryOrCreate
  - name: audit-log
    hostPath:
      path: /var/log/kubernetes/audit
      type: DirectoryOrCreate

The trap that fails this task: adding the arguments and forgetting the volumes. The apiserver runs in a container: if you do not mount the host directories, it can neither see the policy nor write the log, and it goes into a restart loop.

Check:

sudo crictl ps | grep kube-apiserver
sudo tail -3 /var/log/kubernetes/audit/audit.log | head -1

5.7 Pod Security Admission

The built-in admission controller that replaced PodSecurityPolicies. It is switched on by labelling the namespace.

Level What it allows
privileged Everything. No restrictions.
baseline Blocks the obviously dangerous: privileged, hostNetwork, hostPID, dangerous added capabilities
restricted Strong hardening: runAsNonRoot, allowPrivilegeEscalation: false, drop: ALL, seccomp RuntimeDefault
Mode Effect
enforce Rejects non-compliant pods
audit Allows them, but records it in the audit log
warn Allows them and warns the user in the terminal
kubectl label namespace rutas-norte-pro \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/enforce-version=v1.30 \
  pod-security.kubernetes.io/warn=restricted \
  pod-security.kubernetes.io/audit=restricted \
  --overwrite

Check (a non-compliant pod must be rejected):

kubectl run insecure --image=nginx --privileged -n rutas-norte-pro
Error from server (Forbidden): pods "insecure" is forbidden:
violates PodSecurity "restricted:v1.30": privileged (container "insecure"
must not set securityContext.privileged=true), allowPrivilegeEscalation != false,
unrestricted capabilities, runAsNonRoot != true, seccompProfile

Trap: if the namespace already has non-compliant pods, enforce does not evict them; it only blocks new ones. Use warn and audit to spot the existing ones.

5.8 Kyverno or OPA Gatekeeper

When the policy they ask for goes beyond what PSA covers (for example, "only images from the corporate registry"), you use a policy engine.

Kyverno (YAML syntax, more direct):

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: corporate-registry-only
spec:
  validationFailureAction: Enforce
  background: false
  rules:
  - name: check-registry
    match:
      any:
      - resources:
          kinds:
          - Pod
          namespaces:
          - rutas-norte-pro
    validate:
      message: "Only images from registry.rutasnorte.es are allowed"
      pattern:
        spec:
          containers:
          - image: "registry.rutasnorte.es/*"
# Require mandatory labels
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-owner-label
spec:
  validationFailureAction: Enforce
  rules:
  - name: check-owner
    match:
      any:
      - resources:
          kinds: ["Deployment", "StatefulSet"]
    validate:
      message: "The 'owner' label is missing"
      pattern:
        metadata:
          labels:
            owner: "?*"

OPA Gatekeeper (two objects: the template with Rego, and the constraint):

apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8sallowedrepos
spec:
  crd:
    spec:
      names:
        kind: K8sAllowedRepos
      validation:
        openAPIV3Schema:
          type: object
          properties:
            repos:
              type: array
              items:
                type: string
  targets:
  - target: admission.k8s.gatekeeper.sh
    rego: |
      package k8sallowedrepos
      violation[{"msg": msg}] {
        container := input.review.object.spec.containers[_]
        satisfied := [good | repo := input.parameters.repos[_]
                             good := startswith(container.image, repo)]
        not any(satisfied)
        msg := sprintf("image not allowed: %v", [container.image])
      }
---
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sAllowedRepos
metadata:
  name: allowed-registries
spec:
  match:
    kinds:
    - apiGroups: [""]
      kinds: ["Pod"]
    namespaces: ["rutas-norte-pro"]
  parameters:
    repos:
    - "registry.rutasnorte.es/"

Check in both cases:

kubectl run test --image=docker.io/nginx -n rutas-norte-pro
# Error from server: admission webhook denied the request: ...

5.9 gVisor via RuntimeClass

gVisor (the runsc runtime) intercepts system calls in user space, isolating the container from the host kernel. It is used when the workload is not fully trusted.

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc              # must match the handler configured in containerd
apiVersion: v1
kind: Pod
metadata:
  name: untrusted-workload
  namespace: rutas-norte-pre
spec:
  runtimeClassName: gvisor
  containers:
  - name: app
    image: nginx:1.27-alpine

Check (the kernel seen from inside is gVisor's, not the host's):

kubectl exec untrusted-workload -n rutas-norte-pre -- dmesg | head -3
[    0.000000] Starting gVisor...

Trap: the RuntimeClass installs nothing. The runsc handler has to be configured already in the node's /etc/containerd/config.toml. In the exam it usually already is, and all you need is to create the RuntimeClass and assign it.

5.10 Default-deny NetworkPolicies

The pattern you have to be able to write from memory:

# 1. Deny EVERYTHING in the namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-all
  namespace: rutas-norte-pro
spec:
  podSelector: {}
  policyTypes:
  - Ingress
  - Egress
# 2. Allow DNS (essential, and always forgotten)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns
  namespace: rutas-norte-pro
spec:
  podSelector: {}
  policyTypes:
  - Egress
  egress:
  - to:
    - namespaceSelector:
        matchLabels:
          kubernetes.io/metadata.name: kube-system
      podSelector:
        matchLabels:
          k8s-app: kube-dns
    ports:
    - protocol: UDP
      port: 53
    - protocol: TCP
      port: 53
# 3. Allow only what is needed
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-to-postgres
  namespace: rutas-norte-pro
spec:
  podSelector:
    matchLabels:
      app: bookings-api
  policyTypes:
  - Egress
  egress:
  - to:
    - podSelector:
        matchLabels:
          app: bookings-postgres
    ports:
    - protocol: TCP
      port: 5432

Fatal trap: applying the total deny and forgetting DNS. The whole namespace stops resolving names and the symptoms look like something else entirely.


  1. Procedures you must execute without hesitating

Six choreographies that are almost certain to come up. Practise them until they come out without documentation.

6.1 ServiceAccount with minimal RBAC

# 1. The ServiceAccount
kubectl create serviceaccount bookings-app -n rutas-norte-pro

# 2. The Role with the bare minimum
kubectl create role bookings-app-role \
  --verb=get,list \
  --resource=configmaps \
  --resource-name=api-config \
  -n rutas-norte-pro

# 3. The binding
kubectl create rolebinding bookings-app-binding \
  --role=bookings-app-role \
  --serviceaccount=rutas-norte-pro:bookings-app \
  -n rutas-norte-pro

# 4. Assign it to the Deployment
kubectl set serviceaccount deployment/bookings-api bookings-app -n rutas-norte-pro

Check:

kubectl auth can-i get configmap/api-config \
  --as=system:serviceaccount:rutas-norte-pro:bookings-app -n rutas-norte-pro   # yes
kubectl auth can-i get configmaps \
  --as=system:serviceaccount:rutas-norte-pro:bookings-app -n rutas-norte-pro   # no (all of them)
kubectl auth can-i create pods \
  --as=system:serviceaccount:rutas-norte-pro:bookings-app -n rutas-norte-pro   # no

Using --resource-name is the key to "least privilege" and a highly valued answer.

6.2 Disabling automatic token mounting

Three levels, and you have to know which one the statement is asking for:

# A) On the ServiceAccount: affects every pod that uses it
apiVersion: v1
kind: ServiceAccount
metadata:
  name: bookings-app
  namespace: rutas-norte-pro
automountServiceAccountToken: false
# B) On the Pod: wins over the ServiceAccount
apiVersion: v1
kind: Pod
metadata:
  name: bookings-api
  namespace: rutas-norte-pro
spec:
  serviceAccountName: bookings-app
  automountServiceAccountToken: false
  containers:
  - name: api
    image: rutas-norte/bookings-api:2.4
# C) Patch the namespace's "default" ServiceAccount (a very typical task)
kubectl patch serviceaccount default -n rutas-norte-pro \
  -p '{"automountServiceAccountToken": false}'

Check:

kubectl exec bookings-api -n rutas-norte-pro -- ls /var/run/secrets/kubernetes.io/serviceaccount
# ls: ...: No such file or directory

6.3 Encrypting Secrets at rest in etcd

Step 1 — the configuration file, in /etc/kubernetes/enc/enc.yaml:

apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
  - secrets
  providers:
  - aescbc:
      keys:
      - name: key1
        secret: <32_BYTE_BASE64_KEY>
  - identity: {}

The key is generated like this:

head -c 32 /dev/urandom | base64

Provider order (this is what gets asked):

  • The first in the list is the one used to encrypt.
  • All of them are used to decrypt, in order.
  • identity: {} means "unencrypted". If it goes first, everything is written in the clear.

Step 2 — wire it into the apiserver:

    - --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

Step 3 — re-encrypt the Secrets that already exist (they do not encrypt themselves):

kubectl get secrets --all-namespaces -o json | kubectl replace -f -

Check (the value in etcd is no longer in the clear):

sudo ETCDCTL_API=3 etcdctl get /registry/secrets/rutas-norte-pro/bookings-postgres-credentials \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key | hexdump -C | head -3
00000000  2f 72 65 67 69 73 74 72  79 2f 73 65 63 72 65 74  |/registry/secret|
00000020  6b 38 73 3a 65 6e 63 3a  61 65 73 63 62 63 3a 76  |k8s:enc:aescbc:v|

The k8s:enc:aescbc:v1: marker confirms it is encrypted. Without encryption you would see the value in readable form.

6.4 Hardening an insecure pod handed to you

This is the quintessential CKS task: they give you a manifest and ask you to make it compliant. The mental checklist:

What to look for What to put
privileged: true privileged: false (or remove it)
hostNetwork, hostPID, hostIPC set to true Remove them
hostPath pointing at system paths Replace with emptyDir or remove
Missing runAsNonRoot runAsNonRoot: true + runAsUser: <non-0>
Missing allowPrivilegeEscalation allowPrivilegeEscalation: false
Added capabilities (SYS_ADMIN, NET_ADMIN...) capabilities: {drop: ["ALL"]}
Writable filesystem readOnlyRootFilesystem: true
No seccomp profile seccompProfile: {type: RuntimeDefault}
Token mounted unnecessarily automountServiceAccountToken: false
Privileged ports with no need for them Review

A typical result, compliant with the restricted level:

apiVersion: v1
kind: Pod
metadata:
  name: bookings-api-hardened
  namespace: rutas-norte-pro
spec:
  automountServiceAccountToken: false
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    runAsGroup: 10001
    fsGroup: 10001
    seccompProfile:
      type: RuntimeDefault
  containers:
  - name: api
    image: registry.rutasnorte.es/bookings-api:2.4
    securityContext:
      privileged: false
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop: ["ALL"]
    volumeMounts:
    - name: tmp
      mountPath: /tmp
  volumes:
  - name: tmp
    emptyDir: {}

Practical note: with readOnlyRootFilesystem: true, almost any application needs an emptyDir at /tmp to work.

6.5 Isolating a suspicious pod and freezing the analysis

The response procedure for a pod behaving anomalously. Defensive approach: contain, preserve evidence, restore.

# 1. Isolate it from the Service (by removing the label that selects it)
kubectl label pod bookings-api-abc123 -n rutas-norte-pro app-

# 2. Cut its network off completely with a targeted NetworkPolicy
cat <<'EOF' | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: quarantine-api-abc123
  namespace: rutas-norte-pro
spec:
  podSelector:
    matchLabels:
      quarantine: "true"
  policyTypes:
  - Ingress
  - Egress
EOF

kubectl label pod bookings-api-abc123 -n rutas-norte-pro quarantine=true

With no ingress or egress rules, the pod is completely isolated but still alive for analysis. That is the point: do not delete it yet.

# 3. Preserve the evidence
kubectl logs bookings-api-abc123 -n rutas-norte-pro > /opt/evidence/logs.txt
kubectl describe pod bookings-api-abc123 -n rutas-norte-pro > /opt/evidence/describe.txt
kubectl get events -n rutas-norte-pro --sort-by=.lastTimestamp > /opt/evidence/events.txt
sudo journalctl -u falco --since "1 hour ago" > /opt/evidence/falco.txt

# 4. Once the analysis is finished: delete it
kubectl delete pod bookings-api-abc123 -n rutas-norte-pro --force --grace-period=0

The Deployment will recreate a clean pod. If the image was the cause, it has to be fixed first.

6.6 Finding who did what in the audit log

# Look for a specific user's actions
sudo grep '"username":"support"' /var/log/kubernetes/audit/audit.log | tail -5

# Look for access to Secrets
sudo cat /var/log/kubernetes/audit/audit.log | \
  jq 'select(.objectRef.resource=="secrets") | {user:.user.username, verb:.verb, ns:.objectRef.namespace, name:.objectRef.name, t:.requestReceivedTimestamp}'

# Look for deletions in a namespace
sudo cat /var/log/kubernetes/audit/audit.log | \
  jq 'select(.verb=="delete" and .objectRef.namespace=="rutas-norte-pro") | {user:.user.username, res:.objectRef.resource, name:.objectRef.name}'

# With no jq available
sudo grep '"verb":"delete"' /var/log/kubernetes/audit/audit.log | \
  grep 'rutas-norte-pro' | tail -3

Expected output:

{"user":"support","verb":"get","ns":"rutas-norte-pro","name":"bookings-postgres-credentials","t":"2026-08-06T09:12:44Z"}

The audit log fields you have to know:

Field Contents
user.username Who
verb Which action (get, list, create, delete, patch)
objectRef.resource On which kind of resource
objectRef.namespace / .name On which exact object
sourceIPs From where
responseStatus.code Whether it succeeded (200/201) or was denied (403)
requestReceivedTimestamp When
level The level it was logged at

  1. Study plan and exam traps

7.1 A four-week plan (starting from a passed CKA)

Week Focus What to do
1 Cluster and system hardening Revise 08-01, 08-02, 08-03. Run kube-bench and fix every FAIL. Practise AppArmor and seccomp ten times each.
2 Microservices and networking Revise 04-06, 08-04. Write from memory: deny-all, allow-DNS, allow-selective. Pod Security Admission at all three levels. Kyverno with three different policies.
3 Supply chain Revise 08-05, 08-06, 11-03. Scan ten different images with Trivy. Sign and verify with Cosign. Allowed-registry policy. Static analysis of manifests.
4 Runtime, auditing and mock exams Revise 08-06, 07-06. Write three Falco rules. Configure auditing from scratch five times. Encrypt etcd from scratch three times. Timed mock exams.

Key rule: the "edit the apiserver" tasks (auditing, encryption, kube-bench) have to be repeated until the full cycle (edit → wait → verify → recover if it fails) drops below 8 minutes.

7.2 The exam traps

Trap How to avoid it
Editing the apiserver and leaving it broken without noticing Backup in /root/ beforehand, and kubectl get nodes afterwards
Keeping the backup inside /etc/kubernetes/manifests/ It starts as an extra static pod. Keep it outside.
Configuring auditing without mounting the volumes The apiserver will not start. Arguments and volumes, always.
Encrypting etcd and not re-encrypting the existing Secrets The old ones stay in the clear. kubectl get secrets -A -o json | kubectl replace -f -
identity: {} first in the provider list It encrypts nothing. The encryption provider goes first.
Denying all traffic and forgetting DNS The entire namespace stops working
AppArmor profile loaded on the wrong node Pin the node with nodeName or load it on all of them
Absolute path in seccomp's localhostProfile It is relative to /var/lib/kubelet/seccomp/
Applying PSA restricted and expecting it to evict existing pods It only blocks new ones
Editing falco_rules.yaml instead of falco_rules.local.yaml It is lost on the next update; and the exam usually asks for the local one
Not restarting the service after changing configuration systemctl restart falco / restart kubelet
Wasting time installing tools In the exam they are already installed. Find them with which trivy, which kube-bench.

7.3 Official resources

Resource What for
Official CKS page (Linux Foundation) Current curriculum, requirements, price, allowed documentation domains
cncf/curriculum on GitHub The PDF with the exact objectives
kubernetes.io/docs/concepts/security/ The complete security section
falco.org/docs/ Allowed in the exam: rule syntax and fields
aquasecurity.github.io/trivy/ Allowed: flags and output formats
apparmor.net / the AppArmor manual Allowed: profile syntax
Simulator included with enrolment Practice in an equivalent environment

  1. Eight CKS-style tasks solved

Rutas Norte scenarios. Strictly defensive angle.


Task 1 — Harden an insecure pod (weight ~7 %, target: 6 min)

Context: rutas-norte-pro. The Pod notifications-worker runs privileged, with hostPID and with added capabilities. Modify it so it meets the restricted standard: no privileges, no escalation, no capabilities, read-only root, non-root user and the default seccomp profile.

kubectl get pod notifications-worker -n rutas-norte-pro -o yaml > worker.yaml
vim worker.yaml
apiVersion: v1
kind: Pod
metadata:
  name: notifications-worker
  namespace: rutas-norte-pro
spec:
  # hostPID: true  ← REMOVED
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    seccompProfile:
      type: RuntimeDefault
  containers:
  - name: worker
    image: busybox:1.36
    command: ["sleep", "3600"]
    securityContext:
      privileged: false
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop: ["ALL"]
    volumeMounts:
    - name: tmp
      mountPath: /tmp
  volumes:
  - name: tmp
    emptyDir: {}
kubectl delete pod notifications-worker -n rutas-norte-pro
kubectl apply -f worker.yaml

Check:

kubectl get pod notifications-worker -n rutas-norte-pro
kubectl exec notifications-worker -n rutas-norte-pro -- id
kubectl exec notifications-worker -n rutas-norte-pro -- touch /rootfile
uid=10001 gid=0(root)
touch: /rootfile: Read-only file system

The trap: privileged, capabilities and readOnlyRootFilesystem go in the container's securityContext; runAsNonRoot and seccompProfile can go in the pod's. And you have to delete and recreate the pod: almost all of securityContext is immutable.


Task 2 — Audit policy (weight ~9 %, target: 12 min)

Context: rutas-norte-pro. Configure apiserver auditing to log at Metadata level every access to Secrets, at RequestResponse level every change to Pods in rutas-norte-pro, and nothing for read requests on events. The log must go to /var/log/kubernetes/audit/audit.log and be kept for 15 days.

sudo cp /etc/kubernetes/manifests/kube-apiserver.yaml /root/apiserver.bak
sudo mkdir -p /etc/kubernetes/audit /var/log/kubernetes/audit

sudo tee /etc/kubernetes/audit/policy.yaml <<'EOF'
apiVersion: audit.k8s.io/v1
kind: Policy
omitStages:
  - RequestReceived
rules:
- level: None
  verbs: ["get", "list", "watch"]
  resources:
  - group: ""
    resources: ["events"]
- level: Metadata
  resources:
  - group: ""
    resources: ["secrets"]
- level: RequestResponse
  namespaces: ["rutas-norte-pro"]
  resources:
  - group: ""
    resources: ["pods"]
- level: Metadata
EOF

sudo vim /etc/kubernetes/manifests/kube-apiserver.yaml
    - --audit-policy-file=/etc/kubernetes/audit/policy.yaml
    - --audit-log-path=/var/log/kubernetes/audit/audit.log
    - --audit-log-maxage=15
    volumeMounts:
    - mountPath: /etc/kubernetes/audit
      name: audit-policy
      readOnly: true
    - mountPath: /var/log/kubernetes/audit
      name: audit-log
  volumes:
  - name: audit-policy
    hostPath:
      path: /etc/kubernetes/audit
      type: DirectoryOrCreate
  - name: audit-log
    hostPath:
      path: /var/log/kubernetes/audit
      type: DirectoryOrCreate

Check:

sudo crictl ps | grep kube-apiserver
kubectl get secrets -n rutas-norte-pro
sudo tail -1 /var/log/kubernetes/audit/audit.log | jq '{user:.user.username,res:.objectRef.resource,level:.level}'
{"user":"kubernetes-admin","res":"secrets","level":"Metadata"}

The trap: the rules are evaluated in order and the first match wins. If you put - level: Metadata (the catch-all) at the top, none of the rest ever applies. And without the volumes, the apiserver will not start.


Task 3 — Encrypt Secrets in etcd (weight ~9 %, target: 12 min)

Context: rutas-norte-pro. Encrypt Secrets at rest using aescbc. Make sure the Secret bookings-postgres-credentials, which already exists, ends up encrypted.

sudo cp /etc/kubernetes/manifests/kube-apiserver.yaml /root/apiserver.bak
sudo mkdir -p /etc/kubernetes/enc

KEY=$(head -c 32 /dev/urandom | base64)

sudo tee /etc/kubernetes/enc/enc.yaml <<EOF
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
  - secrets
  providers:
  - aescbc:
      keys:
      - name: key1
        secret: ${KEY}
  - identity: {}
EOF

sudo chmod 600 /etc/kubernetes/enc/enc.yaml
sudo vim /etc/kubernetes/manifests/kube-apiserver.yaml
    - --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
# Wait for the API to come back
until kubectl get nodes >/dev/null 2>&1; do sleep 3; done

# Re-encrypt the existing Secrets
kubectl get secrets -n rutas-norte-pro -o json | kubectl replace -f -

Check:

sudo ETCDCTL_API=3 etcdctl get /registry/secrets/rutas-norte-pro/bookings-postgres-credentials \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key | hexdump -C | head -2
00000020  6b 38 73 3a 65 6e 63 3a  61 65 73 63 62 63 3a 76  |k8s:enc:aescbc:v|

The trap: without the kubectl replace, the old Secrets stay in the clear and the task does not score. And identity: {} must come after aescbc, never before.


Task 4 — Default-deny NetworkPolicy (weight ~7 %, target: 7 min)

Context: rutas-norte-pro. Apply default deny to all inbound and outbound traffic in the namespace. Then allow the pods to keep resolving DNS and let bookings-api reach bookings-postgres on 5432.

cat <<'EOF' | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-all
  namespace: rutas-norte-pro
spec:
  podSelector: {}
  policyTypes: [Ingress, Egress]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns
  namespace: rutas-norte-pro
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
  - to:
    - namespaceSelector:
        matchLabels:
          kubernetes.io/metadata.name: kube-system
    ports:
    - protocol: UDP
      port: 53
    - protocol: TCP
      port: 53
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-to-postgres
  namespace: rutas-norte-pro
spec:
  podSelector:
    matchLabels:
      app: bookings-api
  policyTypes: [Egress]
  egress:
  - to:
    - podSelector:
        matchLabels:
          app: bookings-postgres
    ports:
    - protocol: TCP
      port: 5432
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: postgres-from-api
  namespace: rutas-norte-pro
spec:
  podSelector:
    matchLabels:
      app: bookings-postgres
  policyTypes: [Ingress]
  ingress:
  - from:
    - podSelector:
        matchLabels:
          app: bookings-api
    ports:
    - protocol: TCP
      port: 5432
EOF

Check:

kubectl exec deploy/bookings-api -n rutas-norte-pro -- nslookup bookings-postgres
kubectl exec deploy/bookings-api -n rutas-norte-pro -- nc -zv bookings-postgres 5432
kubectl exec deploy/web-store -n rutas-norte-pro -- nc -zv -w 3 bookings-postgres 5432   # must fail

The trap: you have to open both directions. The egress from bookings-api is not enough if bookings-postgres has ingress denied. And without the DNS policy, the name is not even resolved.


Task 5 — Trivy scan and withdrawal of vulnerable images (weight ~6 %, target: 6 min)

Context: rutas-norte-pre. Scan the images of the pods in the namespace. Delete the pods whose image has CRITICAL severity vulnerabilities. Save the list of discarded images in /opt/vulnerable.txt.

kubectl get pods -n rutas-norte-pre \
  -o custom-columns='POD:.metadata.name,IMG:.spec.containers[*].image' --no-headers
web-store-1      nginx:1.27-alpine
bookings-api-1   node:18.0.0
redis-1          redis:7-alpine
worker-1         ubuntu:20.04
> /opt/vulnerable.txt
for img in nginx:1.27-alpine node:18.0.0 redis:7-alpine ubuntu:20.04; do
  n=$(trivy image --severity CRITICAL --quiet --format json "$img" \
      | jq '[.Results[].Vulnerabilities // []] | flatten | length')
  echo "$img -> CRITICAL: $n"
  [ "$n" -gt 0 ] && echo "$img" >> /opt/vulnerable.txt
done
nginx:1.27-alpine -> CRITICAL: 0
node:18.0.0 -> CRITICAL: 7
redis:7-alpine -> CRITICAL: 0
ubuntu:20.04 -> CRITICAL: 3
kubectl delete pod bookings-api-1 worker-1 -n rutas-norte-pre
cat /opt/vulnerable.txt

The trap: if the pods belong to a Deployment, deleting them achieves nothing: they are recreated with the same image. You have to scale to 0 or delete the Deployment, depending on what the statement asks. Read it carefully.


Task 6 — AppArmor on a pod (weight ~7 %, target: 8 min)

Context: rutas-norte-pro. On node node-1 there is an unloaded AppArmor profile at /opt/profiles/deny-write. Load it in enforce mode and create the Pod auditor (image busybox:1.36, command sleep 3600) on that node with that profile applied.

ssh node-1
sudo apparmor_parser -q /opt/profiles/deny-write
sudo aa-status | grep deny-write
exit
   k8s-deny-write

The name shown by aa-status is the one you have to use, not the file name.

apiVersion: v1
kind: Pod
metadata:
  name: auditor
  namespace: rutas-norte-pro
spec:
  nodeName: node-1
  containers:
  - name: auditor
    image: busybox:1.36
    command: ["sleep", "3600"]
    securityContext:
      appArmorProfile:
        type: Localhost
        localhostProfile: k8s-deny-write

Check:

kubectl get pod auditor -n rutas-norte-pro -o wide
kubectl exec auditor -n rutas-norte-pro -- touch /tmp/x
touch: /tmp/x: Permission denied

The trap: nodeName: node-1 is essential; if the pod is scheduled on another node where the profile is not loaded, it ends up Blocked with the event cannot enforce AppArmor profile. And if the pod stays Pending, check that node-1 is not cordoned.


Task 7 — Falco rule and evidence capture (weight ~8 %, target: 9 min)

Context: rutas-norte-pro. Falco is installed on node-1. Change the output of the rule that detects shells in containers so it shows exactly time,user,container-id,container-name,process, and save the detections from the last 10 minutes into /opt/shells.log.

ssh node-1

sudo tee -a /etc/falco/falco_rules.local.yaml <<'EOF'

- rule: Terminal shell in container
  output: >
    %evt.time,%user.name,%container.id,%container.name,%proc.name
  override:
    output: replace
EOF

sudo falco --validate /etc/falco/falco_rules.local.yaml
sudo systemctl restart falco
sudo systemctl status falco --no-pager | head -5
# Trigger a detection to verify (from another terminal)
kubectl exec -it deploy/bookings-api -n rutas-norte-pro -- sh -c "echo test"

# Capture the evidence
sudo journalctl -u falco --since "10 minutes ago" --no-pager \
  | grep "Terminal shell" > /opt/shells.log
cat /opt/shells.log
09:41:07.882,root,a1b2c3d4e5f6,bookings-api,sh

The trap: you have to edit falco_rules.local.yaml, never falco_rules.yaml. And without systemctl restart falco the change has no effect. If falco --validate reports a syntax error, fix it before restarting: an invalid file leaves the service down.


Task 8 — Admission policy: allowed registries only (weight ~8 %, target: 9 min)

Context: rutas-norte-pro. Kyverno is installed. Create a policy that rejects, in rutas-norte-pro, any Pod whose image does not come from registry.rutasnorte.es/. Prove that it works.

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: corporate-registry-only
spec:
  validationFailureAction: Enforce
  background: false
  rules:
  - name: check-registry
    match:
      any:
      - resources:
          kinds:
          - Pod
          namespaces:
          - rutas-norte-pro
    validate:
      message: "Only images from registry.rutasnorte.es/ are allowed"
      pattern:
        spec:
          =(initContainers):
          - image: "registry.rutasnorte.es/*"
          containers:
          - image: "registry.rutasnorte.es/*"
kubectl apply -f registry-policy.yaml
kubectl get clusterpolicy corporate-registry-only

Check:

# Must be rejected
kubectl run test-ko --image=docker.io/nginx:1.27 -n rutas-norte-pro
Error from server: admission webhook "validate.kyverno.svc-fail" denied the request:

policy Pod/rutas-norte-pro/test-ko for resource violation:
corporate-registry-only:
  check-registry: 'validation error: Only images from
  registry.rutasnorte.es/ are allowed'
# Must be admitted
kubectl run test-ok --image=registry.rutasnorte.es/nginx:1.27 -n rutas-norte-pro

The trap: validationFailureAction: Audit only records; to reject you need Enforce. And the =() prefix on initContainers means "if this field exists, validate it"; without it, a pod with no initContainers would fail validation.


Common Mistakes and Tips

Mistakes that cost points

Mistake Consequence Prevention
Breaking the apiserver with no backup You lose every task on that cluster cp to /root/ before editing
Backup inside manifests/ Two apiservers in conflict Keep it outside that directory
Audit arguments with no volumes The apiserver will not start Arguments and volumeMounts and volumes
Catch-all rule at the top of the audit policy The rest never apply Order matters: specific to general
identity: {} before the encryption provider Nothing gets encrypted The encrypter goes first
Not re-encrypting the existing Secrets Half the task unscored kubectl get secrets -A -o json | kubectl replace -f -
Denying everything and forgetting DNS The namespace becomes unusable The DNS policy always goes alongside the deny policy
Absolute path in localhostProfile (seccomp) The pod will not start Path relative to /var/lib/kubelet/seccomp/
Editing falco_rules.yaml Change in the wrong file Use falco_rules.local.yaml
Forgetting systemctl restart after changing config The change does not take effect Restart and verify with status
Modifying securityContext with kubectl edit Immutable fields: it fails Export, delete, recreate
Deleting pods managed by a Deployment They are recreated identically Scale to 0 or fix the template

Tips

  1. Repeat the apiserver tasks until they are automatic. Auditing and encryption are worth the most points and eat the most time if you hesitate.
  2. Keep an until kubectl get nodes handy to wait for the API to come back instead of staring at the screen.
  3. Check which tools are installed as soon as you start a task that needs them: which trivy kube-bench falco.
  4. The Falco and Trivy documentation is allowed. Know where the Falco field section is (%evt.time, %container.name, ...) and the Trivy flags section.
  5. Always verify with a negative test. It is not enough for the allowed case to work: you have to see the forbidden one fail.
  6. Think defence, not offence. The exam measures hardening, detection and response. Every correct answer points that way.
  7. Remember the requirement: no valid CKA, no CKS.

Exercises

Exercise 1 — Full apiserver hardening cycle

On a practice cluster built with kubeadm, and timing yourself:

  1. Run kube-bench run --targets=master and note down every [FAIL] in section 1.2.
  2. Fix at least three of them by editing /etc/kubernetes/manifests/kube-apiserver.yaml.
  3. Verify that the API comes back and that kube-bench now shows [PASS] on those controls.
  4. Back up the manifest before you start and demonstrate that you know how to restore it.

Target: 20 minutes, with the cluster working at the end.

Exercise 2 — Responding to a suspicious pod

Simulate the full containment procedure in rutas-norte-pro:

  1. Create a Deployment bookings-api with 3 replicas and a Service that selects it.
  2. Pick one of the pods as the "suspect".
  3. Isolate it from the Service without killing it.
  4. Cut off all its network communication with a targeted NetworkPolicy, leaving the rest of the namespace working.
  5. Preserve logs, describe and events in /opt/evidence/.
  6. Check that the Service is still serving with 2 endpoints and that the isolated pod has no connectivity.

Exercise 3 — End-to-end supply chain

For the image nginx:1.27-alpine:

  1. Scan it with Trivy filtering by CRITICAL and HIGH with a patch available.
  2. Create a Kyverno policy requiring every pod in rutas-norte-pro to have runAsNonRoot: true and allowPrivilegeEscalation: false.
  3. Prove that a non-compliant pod is rejected and a compliant one is admitted.
  4. Also enable Pod Security Admission at restricted level in warn mode on rutas-norte-pre and check the warning.

Solutions

Solution to Exercise 1

# 0. Backup ALWAYS first
sudo cp /etc/kubernetes/manifests/kube-apiserver.yaml /root/apiserver.bak

# 1. Diagnosis
kube-bench run --targets=master | grep -E '^\[FAIL\]' | head
[FAIL] 1.2.20 Ensure that the --profiling argument is set to false
[FAIL] 1.2.21 Ensure that the --audit-log-path argument is set
[FAIL] 1.2.22 Ensure that the --audit-log-maxage argument is set to 30 or as appropriate
# 2. Correction
sudo vim /etc/kubernetes/manifests/kube-apiserver.yaml
    - --profiling=false
    - --audit-log-path=/var/log/kubernetes/audit/audit.log
    - --audit-log-maxage=30

Remembering that --audit-log-path needs its volume:

    volumeMounts:
    - mountPath: /var/log/kubernetes/audit
      name: audit-log
  volumes:
  - name: audit-log
    hostPath:
      path: /var/log/kubernetes/audit
      type: DirectoryOrCreate
# 3. Wait and verify
until kubectl get nodes >/dev/null 2>&1; do sleep 3; done
kubectl get nodes
kube-bench run --targets=master --check=1.2.20,1.2.21,1.2.22
[PASS] 1.2.20 Ensure that the --profiling argument is set to false
[PASS] 1.2.21 Ensure that the --audit-log-path argument is set
[PASS] 1.2.22 Ensure that the --audit-log-maxage argument is set to 30

4. Restore (to practise recovery):

sudo cp /root/apiserver.bak /etc/kubernetes/manifests/kube-apiserver.yaml
until kubectl get nodes >/dev/null 2>&1; do sleep 3; done

The usual mistake in this exercise is adding --audit-log-path without the volume: the apiserver starts and dies in a loop, and the diagnosis is sudo crictl logs <id>.

Solution to Exercise 2

# 1. Setup
kubectl create deployment bookings-api --image=nginx:1.27-alpine \
  --replicas=3 -n rutas-norte-pro
kubectl expose deployment bookings-api --port=80 -n rutas-norte-pro
kubectl get endpoints bookings-api -n rutas-norte-pro
NAME           ENDPOINTS
bookings-api   10.244.1.4:80,10.244.1.5:80,10.244.2.3:80
# 2-3. Pick one and isolate it from the Service
SUSPECT=$(kubectl get pods -n rutas-norte-pro -l app=bookings-api \
  -o jsonpath='{.items[0].metadata.name}')
kubectl label pod $SUSPECT -n rutas-norte-pro app-
kubectl label pod $SUSPECT -n rutas-norte-pro quarantine=true

Careful: when you remove the app label, the ReplicaSet considers it lost and creates a new pod. That is exactly what you want: the service recovers on its own while the suspect stays alive for analysis.

# 4. Total network cut-off for the quarantined pod
cat <<'EOF' | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: quarantine
  namespace: rutas-norte-pro
spec:
  podSelector:
    matchLabels:
      quarantine: "true"
  policyTypes: [Ingress, Egress]
EOF

# 5. Evidence
mkdir -p /opt/evidence
kubectl logs $SUSPECT -n rutas-norte-pro > /opt/evidence/logs.txt
kubectl describe pod $SUSPECT -n rutas-norte-pro > /opt/evidence/describe.txt
kubectl get events -n rutas-norte-pro --sort-by=.lastTimestamp > /opt/evidence/events.txt

# 6. Verification
kubectl get endpoints bookings-api -n rutas-norte-pro    # 3 endpoints, none of them the suspect
kubectl exec $SUSPECT -n rutas-norte-pro -- \
  timeout 3 wget -qO- http://bookings-api || echo "isolated correctly"
isolated correctly

A NetworkPolicy with no ingress or egress rules is a total cut-off. The pod still exists, its logs can be read (that goes through the API, not through its network) and kubectl exec works for the same reason.

Solution to Exercise 3

# 1. Scan
trivy image --severity CRITICAL,HIGH --ignore-unfixed nginx:1.27-alpine
nginx:1.27-alpine (alpine 3.20.3)
Total: 0 (HIGH: 0, CRITICAL: 0)

--ignore-unfixed is key: it filters out the noise of vulnerabilities with no patch available, which you cannot remediate by upgrading.

# 2. Kyverno policy
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-non-root
spec:
  validationFailureAction: Enforce
  background: false
  rules:
  - name: check-securitycontext
    match:
      any:
      - resources:
          kinds: ["Pod"]
          namespaces: ["rutas-norte-pro"]
    validate:
      message: "Pods must set runAsNonRoot=true and allowPrivilegeEscalation=false"
      pattern:
        spec:
          =(securityContext):
            runAsNonRoot: true
          containers:
          - securityContext:
              allowPrivilegeEscalation: false
kubectl apply -f require-non-root.yaml

# 3. Negative test
kubectl run bad --image=nginx:1.27-alpine -n rutas-norte-pro
# Error from server: admission webhook denied the request: ...

# 3b. Positive test
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: good
  namespace: rutas-norte-pro
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
  containers:
  - name: c
    image: nginx:1.27-alpine
    securityContext:
      allowPrivilegeEscalation: false
EOF
kubectl get pod good -n rutas-norte-pro
# 4. Pod Security Admission in warn mode
kubectl label namespace rutas-norte-pre \
  pod-security.kubernetes.io/warn=restricted \
  pod-security.kubernetes.io/warn-version=v1.30 --overwrite

kubectl run warned --image=nginx:1.27-alpine -n rutas-norte-pre
Warning: would violate PodSecurity "restricted:v1.30": allowPrivilegeEscalation != false,
unrestricted capabilities, runAsNonRoot != true, seccompProfile
pod/warned created

Note the difference: Kyverno with Enforce rejects; PSA in warn mode warns and creates. Knowing how to choose the mechanism and the mode according to what the statement asks is exactly what this domain measures.

Reminder: these policies are study exercises. Before applying anything like them to a real environment, have it reviewed by a security professional who knows the organisation's context.


Conclusion

The CKS is the certification that closes the triangle: the CKA proves you can keep the cluster standing, the CKAD that you can build on it, and the CKS that you can defend it. It is the most demanding of the three because it takes you out of kubectl and into the node's files, external tools and decisions where a mistake leaves the control plane down.

The essentials of this lesson:

  • The CKS requires a valid CKA to sit. There is no way around it.
  • Beyond kubernetes.io, the exam allows the Trivy, Falco and AppArmor documentation. Make the most of it.
  • The three heaviest domains —microservices, supply chain and runtime, ~60 % between them— are the ones from module 8 of this course.
  • Nine tools have to be handled without hesitation: kube-bench, Trivy, Falco, AppArmor, seccomp, audit-policy, Pod Security Admission, Kyverno/OPA and gVisor with RuntimeClass, plus default-deny NetworkPolicies.
  • Six procedures must be on autopilot: minimal RBAC, disabling token automounting, encrypting etcd, hardening a pod, isolating a suspicious pod and reading the audit log.
  • Before touching the apiserver: back it up outside manifests/. Afterwards: verify that the API comes back.
  • The traps that fail the most people: forgotten volumes in the audit configuration, identity first in the encryption list, Secrets left un-re-encrypted, DNS blocked by the total deny, and AppArmor profiles on the wrong node.
  • Always check the current official curriculum on the Linux Foundation / CNCF website, including the list of allowed documentation, which has changed between revisions.
  • And remember: any security configuration destined for a real environment must be reviewed by a security professional.

You now know the three certifications, their curricula and how they map onto what you have studied. What is missing is the part none of the three teaches, and which decides more passes than it seems: exam technique. In the next lesson we will look at how to prepare the proctoring environment, what to type in the first sixty seconds of the exam, how to use the allowed documentation without losing time, how to divide the minutes between tasks, which mistakes cost the most points and what to do —before, during and after— so that exam day holds no surprises.

Kubernetes Course

Module 1: Introduction to Kubernetes

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved