KCSA — Kubernetes Security Associate
Allowed to Create Pods, but Privileged Pods Must Be Stopped
Goal
With ValidatingAdmissionPolicy (VAP), write the policies "no privileged containers" and "no hostPath volumes" yourself in CEL, turn them on for just one namespace with a binding, and then observe both the rejection message the API server returns and the admission of a normal Pod.
Why it matters
Every Kubernetes write request must go through authentication → authorization (RBAC) → admission before it is stored in etcd. RBAC
answers only "may this user create a Pod?" and does not ask "is that Pod's content safe?"
With only the permission to create Pods, a Pod with privileged: true or one that mounts the node's / as a hostPath can
take over the whole node. The layer that closes that hole is admission control.
Pod Security Admission (PSA) turns on a fixed standard (privileged, baseline, or restricted) with a single namespace label. It is convenient, but it cannot express an organization's own rules (allowed registries, required specific labels, and so on). VAP evaluates CEL expressions directly inside the API server, so you can write the rules you want without a webhook server, and because the policy (what to check) and the binding (where, and with which of Deny/Warn/Audit) are separated, you can turn on the same policy differently per namespace.
The rejection message prints by name which policy and which binding blocked the request. In incident response, the fastest clue to "why won't the deployment go through" is exactly this one line. Also remember that admission happens only at the time of the create request, so objects that were already created before you turned the policy on stay as they are.
Steps
- Create the namespace
kcsa-admand check the automatic label. - Write, in CEL, the policy
deny-privilegedthat rejects privileged containers. - Turn it on as Deny for
kcsa-admonly with the bindingdeny-privileged-binding. - Try to create a privileged Pod and save the rejection message to
/root/kcsa-adm/denied-privileged.txt. - Confirm that an ordinary Pod
goodis admitted. - Create the policy
deny-hostpaththat rejects hostPath volumes, and a binding. - Try to create a hostPath Pod and save the rejection message to
/root/kcsa-adm/denied-hostpath.txt. - Leave what was blocked and what passed in
/root/kcsa-adm/report.txt.
Notes
- If you create only the policy and forget the binding, nothing is blocked. First check with
kubectl get validatingadmissionpolicybindingthat both exist. - If you set
validationActionstoWarn, the request passes and only a warning appears. To block, it must beDeny. - The Pods in this lab are imitated without containers, but admission happens at the API request stage, so the rejection and admission are real.
- Official documentation: Validating Admission Policy · Pod Security Standards.
Create an isolated area to apply the policies to
Create the namespace kcsa-adm. The API server automatically attaches the label kubernetes.io/metadata.name=kcsa-adm to this namespace, and the binding in a later step selects its target with this label.
Kubernetes automatically attaches to every namespace a kubernetes.io/metadata.name label whose value is its own name. After creating it, check with kubectl get ns <이름> --show-labels (the placeholder is the namespace name). If you generate the create with --dry-run=client and apply it, it is safe to run several times.
Write the rule that blocks privileged containers in CEL
Create a ValidatingAdmissionPolicy deny-privileged. Write a CEL expression that rejects a Pod CREATE request if even one container has securityContext.privileged set to true, and set the rejection message to privileged containers are not allowed. failurePolicy is Fail.
The policy decides which requests to look at (core group v1 pods, CREATE) with matchConstraints.resourceRules, and validations[].expression must be true to pass. Ask whether all containers satisfy the condition, as in object.spec.containers.all(c, ...), but a container that has no securityContext or privileged field at all must also count as passing, so check with has() first. A policy alone blocks nothing.
Bind the rule to kcsa-adm and actually turn it on
Create a ValidatingAdmissionPolicyBinding deny-privileged-binding. policyName is deny-privileged, validationActions is ["Deny"], and the target selects only the namespace with kubernetes.io/metadata.name: kcsa-adm through the matchLabels of matchResources.namespaceSelector.
In VAP, the policy (what to check) and the binding (where, and with what action) are separate. Without a binding, the policy is not even evaluated. validationActions also has Warn and Audit besides Deny, but only with Deny is the request actually rejected. When selecting the namespace, use the automatic label you checked in step 1.
A privileged Pod is turned away at the door
In the namespace kcsa-adm, try to create a Pod (image nginx:1.27-alpine) with a container that has securityContext.privileged: true, and save the entire rejection message the API server returns to /root/kcsa-adm/denied-privileged.txt.
The rejection happens before the Pod is created, so kubectl prints the error to standard error with a nonzero exit code. You must capture standard error in the file too (2>&1). The message includes by name which policy and which binding rejected it. Right after the binding, it may take 1–2 seconds to take effect, so if the Pod got created, delete it and try again.
An ordinary Pod goes in without a hitch
In the namespace kcsa-adm, create an ordinary Pod good (image nginx:1.27-alpine) with no privileged settings. Even with the policy on, this Pod must be admitted.
A good policy blocks only bad requests and lets normal requests through as they are. Whether your CEL expression treats a container without a securityContext as passing shows up here. If it was created, it appears with kubectl get pod good -n kcsa-adm, and if it was blocked, you get an error of the same shape as in step 4.
Also block hostPath, which leads to the node disk
Create a ValidatingAdmissionPolicy deny-hostpath and a binding deny-hostpath-binding. The policy rejects a Pod CREATE that has even one hostPath volume (message hostPath volumes are not allowed, failurePolicy: Fail), and the binding applies with validationActions ["Deny"] to only the kubernetes.io/metadata.name: kcsa-adm namespace.
The structure is the same as steps 2 and 3, and only the check target changes to volumes. Some Pods have no volumes field at all, so check first with has(object.spec.volumes), and ask with has() whether each volume has a hostPath field. You may also join the policy and binding with --- and apply them at once.
A Pod that tried to mount the node's /etc is rejected
In the namespace kcsa-adm, try to create a Pod (image nginx:1.27-alpine) that mounts the node's /etc as a hostPath volume, and save the entire rejection message to /root/kcsa-adm/denied-hostpath.txt.
It has the shape of putting a hostPath volume in the Pod spec's volumes and attaching it with the container's volumeMounts. Capture standard error in the file as in step 4. Check that the policy and binding names printed in this message differ from those in step 4.
Leave a ledger of what was blocked and what passed
Write the observed results to /root/kcsa-adm/report.txt in four lines — privileged=denied, hostpath=denied, plain=allowed, enforcement=validatingadmissionpolicy. The grader compares each line by actually creating Pods in the cluster.
The value is either denied or allowed, and the last line writes, in a single lowercase word, who enforced this rejection (which admission mechanism it was, rather than a PSA label). Carry over the results you saw directly in steps 4, 5, and 7, not guesses.