Permitted Registries for the CKS a ValidatingAdmissionPolicy and the four gaps a first policy leaves open
Write the rule, then attack it: init containers, lookalike hosts, missing fields and debug containers.
The problem
The CNCF curriculum for the Certified Kubernetes Security Specialist (CKS) exam lists "Secure your supply chain (permitted registries, sign and validate artifacts, etc.)" under the domain "20% - Supply Chain Security". A registry is a server that stores container images, and a permitted-registries rule decides which ones a cluster may pull from.
Suppose a team pulls images only from its own registry, registry.example.com. It wants the Kubernetes API (Application Programming Interface) server to refuse any Pod whose image comes from somewhere else. The rule should cover the payments namespace only, and the team does not want to run a webhook server. One trap is that "Images can also include a registry hostname", and "If you don't specify a registry hostname, Kubernetes assumes that you mean the Docker public registry." A bare nginx therefore quietly means a public registry, and the rule has to catch it.
The lab behind this lesson ran on 2026-10-30 against a bare Kubernetes v1.37.1 API server with etcd 3.6.15 (the key-value store behind the API) on an arm64 Mac. It had no nodes, no scheduler and no controller manager. Pods were admitted or rejected but never ran, so they show Pending. No audit log was configured. Quoted output is labelled by lab step number.
The idea, in plain words
A ValidatingAdmissionPolicy holds rules written in CEL (Common Expression Language) and says which requests those rules apply to. The API server evaluates them in-process, so no webhook server is needed. The policy does nothing by itself. A ValidatingAdmissionPolicyBinding switches it on for a scope, such as namespaces carrying a label, and says what a failure does: Deny the request, Warn the client, or record an Audit event. The rule should be written over every image in the Pod, the match should include the subresource that adds debug containers, and the rollout should start with Warn, because a wrong rule in Deny mode blocks legitimate work.
1. Policy plus binding, and the three actions
Admission control is the step inside the API server, after authentication and authorization, that can reject a request before it is stored. A ValidatingAdmissionPolicy is the object that declares validation rules for that step. A ValidatingAdmissionPolicyBinding, called a binding from here on, links a policy to a scope and chooses what a failure does. The scope in this lab is a namespaceSelector, a label query that picks which namespaces the binding covers. Because the binding carries both the scope and the action, the lab moved from warning to enforcing in step 3 by editing only the binding.
The payments namespace carries one label:
name: payments
labels:
registry-policy: enforced
Here is the binding that uses it:
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: allowed-registries-payments
spec:
policyName: allowed-registries
validationActions: [Warn, Audit]
matchResources:
namespaceSelector:
matchLabels:
registry-policy: enforced
Three fields do the work. policyName points at the policy allowed-registries by name. validationActions lists the outcomes: Deny rejects the request, Warn reports the failure to the client as a warning, and Audit includes it in the audit event for the API request. matchResources.namespaceSelector limits the binding to namespaces labelled registry-policy: enforced, which is why payments is covered and any unlabelled namespace is not.
Steps 1 and 2 of the lab show the first two states:
$ kubectl apply -f manifests/pod-hub.yaml
pod/hub created
[exit 0]
$ kubectl apply -f manifests/pod-hub.yaml
Warning: Validation failed for ValidatingAdmissionPolicy 'allowed-registries' with binding 'allowed-registries-payments': images must come from registry.example.com, got: docker.io/library/nginx:1.27
pod/hub created
[exit 0]
In step 1 the policy existed alone and the Docker Hub Pod hub was created. In step 2 the Warn binding made the same request succeed, but the client received a warning naming the policy, the binding and the offending image. Warn is a safe first setting, since nothing breaks while the team learns what the rule would reject. The docs state that "Deny and Warn may not be used together", since the combination repeats the same failure in two places, and they document [Warn, Audit] as the pairing. Audit was not tested here because the lab had no audit log.
Step 3 changed one line of the binding to validationActions: [Deny]:
$ kubectl apply -f manifests/pod-good.yaml
pod/good created
[exit 0]
$ kubectl apply -f manifests/pod-hub.yaml
The pods "hub" is invalid: : ValidatingAdmissionPolicy 'allowed-registries' with binding 'allowed-registries-payments' denied request: images must come from registry.example.com, got: docker.io/library/nginx:1.27
[exit 1]
$ kubectl apply -f manifests/pod-hub-sandbox.yaml
pod/hub created
[exit 0]
The compliant Pod good was created and the public-image Pod was rejected with exit code 1. The third command created a hub Pod in the sandbox namespace. That namespace lacks the label, so the binding never applies to it. The selector is the only thing limiting the blast radius of the policy, so a typo in the label name would leave payments unprotected.
Symptom: The policy is created without error, and a Docker Hub Pod is still admitted with no warning.
Cause: A policy alone matches nothing. The docs say "a binding is required", and step 1 shows pod/hub created with only the policy present.
Fix: Create the binding. Running kubectl get validatingadmissionpolicybindings is a quick way to see which bindings exist (a command to try, since the lab did not run it).
2. Writing the CEL rule
CEL is the expression language the rules are written in. Inside a rule, object is "The object from the incoming request", here the Pod. matchConstraints says which requests the policy applies to, by API group, version, operation and resource. An expression is the rule, and a request for which it is true is admitted. A messageExpression builds the text shown on failure, and "Unlike validations, message expression must evaluate to a string." The reason field sets the status reason returned to the client, with Unauthorized, Forbidden, Invalid and RequestEntityTooLarge as the supported values. The docs add: "If not set, StatusReasonInvalid is used in the response to the client."
# First attempt: checks spec.containers only.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: allowed-registries
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
validations:
- expression: >-
object.spec.containers.all(c, c.image.startsWith('registry.example.com'))
messageExpression: >-
'images must come from registry.example.com, got: ' +
object.spec.containers.map(c, c.image).join(', ')
The call all(c, test) is true only when the test passes for every element of a list, so one public image fails the whole rule. The call map(c, c.image) builds a new list holding each container's image string, and join(', ') turns that list into text for the message. The test itself, startsWith, is a plain string comparison. This first attempt reads only object.spec.containers, which leaves the gaps shown next.
Because policy-v1 sets no reason, the step 3 rejection begins The pods "hub" is invalid. The fixed policy in section 3 sets reason: Forbidden, and its rejections begin Error from server (Forbidden) (step 6). The docs say the reason and its HTTP response code are what the client receives.
Two Pods slip past policy-v1. The first hides the public image in an init container:
apiVersion: v1
kind: Pod
metadata:
name: init-sneak
namespace: payments
spec:
initContainers:
- name: setup
image: docker.io/library/busybox:1.36
containers:
- name: app
image: registry.example.com/payments/api:1.4.2
The main container uses the permitted registry, so the rule over spec.containers passes. The public busybox image sits in spec.initContainers, a second container list that policy-v1 never reads. The API server hands the whole Pod to the policy, but CEL checks only the fields the expression names, so an unnamed list is never checked. The second Pod, lookalike, uses this image line:
image: registry.example.com.attacker.net/api:1.4.2
Step 4 shows the result under policy-v1 with the Deny binding active:
$ kubectl apply -f manifests/pod-init.yaml
pod/init-sneak created
[exit 0]
$ kubectl apply -f manifests/pod-lookalike.yaml
pod/lookalike created
[exit 0]
$ kubectl get pods -n payments
NAME READY STATUS RESTARTS AGE
good 0/1 Pending 0 1s
init-sneak 0/1 Pending 0 0s
lookalike 0/1 Pending 0 0s
Symptom: A Pod with image registry.example.com.attacker.net/api:1.4.2 is admitted.
Cause: startsWith('registry.example.com') is a string test, and the attacker's hostname begins with the same characters.
Fix: Include the slash, startsWith('registry.example.com/'), so only images under that registry host match. Step 6 rejects the lookalike:
$ kubectl apply -f manifests/pod-lookalike.yaml
Error from server (Forbidden): error when creating "manifests/pod-lookalike.yaml": pods "lookalike" is forbidden: ValidatingAdmissionPolicy 'allowed-registries' with binding 'allowed-registries-payments' denied request: images must come from registry.example.com/, rejected: registry.example.com.attacker.net/api:1.4.2
[exit 1]
3. Every container list, and has()
A Pod can name images in three places. Containers are the main list. Init containers, listed under spec.initContainers, run before them. Ephemeral containers are added to a running Pod for debugging, and the docs say they "are created using a special ephemeralcontainers handler in the API rather than by adding them directly to pod.spec". A policy that checks one list leaves the other two open, so the rule has to gather all three. CEL variables help here. The docs define one as "a named expression that can be referred later in variables in other expressions", and it is "lazily evaluated when it is first referred", with errors "reported during the evaluation of the referring expression".
The catch is that initContainers and ephemeralContainers are optional fields, and most Pods omit them. The CEL has() macro "checks whether a field is present before a CEL expression accesses the field". Reading an absent field without it is an error. The lab's broken variant, policy-nohas.yaml, differs from the fixed policy-v2.yaml only in the variable expression:
17,19c17
< object.spec.containers.map(c, c.image) +
< (has(object.spec.initContainers) ? object.spec.initContainers.map(c, c.image) : []) +
< (has(object.spec.ephemeralContainers) ? object.spec.ephemeralContainers.map(c, c.image) : [])
---
> object.spec.containers.map(c, c.image) + object.spec.initContainers.map(c, c.image)
The lines starting with < are the fixed version and the line starting with > is the broken one. The broken line reads object.spec.initContainers unconditionally, so it works for a Pod that has init containers and fails for one that does not. The fixed lines use a conditional: if the field is present, map its images, otherwise add an empty list. Step 5 shows both halves of that behavior:
$ kubectl apply -f manifests/pod-init.yaml
Error from server (Forbidden): error when creating "manifests/pod-init.yaml": pods "init-sneak" is forbidden: ValidatingAdmissionPolicy 'allowed-registries' with binding 'allowed-registries-payments' denied request: images must come from registry.example.com/, rejected: docker.io/library/busybox:1.36
[exit 1]
$ kubectl apply -f manifests/pod-hub.yaml
The pods "hub" is invalid: : ValidatingAdmissionPolicy 'allowed-registries' with binding 'allowed-registries-payments' denied request: expression 'variables.images.all(i, i.startsWith('registry.example.com/'))' resulted in error: composited variable "images" fails to evaluate: no such key: initContainers
[exit 1]
$ kubectl create --dry-run=server -f manifests/pod-good-new.yaml
The pods "good-new" is invalid: : ValidatingAdmissionPolicy 'allowed-registries' with binding 'allowed-registries-payments' denied request: expression 'variables.images.all(i, i.startsWith('registry.example.com/'))' resulted in error: composited variable "images" fails to evaluate: no such key: initContainers
[exit 1]
The init-sneak Pod was rejected as intended, because it has an init container. But hub and even the compliant good-new (a second permitted-registry Pod, sent as a server dry run so nothing is stored) were rejected too. The cause is failurePolicy: Fail. The docs say "Fail means that an error calling the ValidatingAdmissionPolicy causes the admission to fail and the API request to be rejected." A CEL error therefore rejects every Pod without init containers, good or bad.
The fixed policy, with the slash and all three lists, is policy-v2.yaml:
# Fixed: every container list, and the registry name ends with a slash.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: allowed-registries
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods", "pods/ephemeralcontainers"]
variables:
- name: images
expression: >-
object.spec.containers.map(c, c.image) +
(has(object.spec.initContainers) ? object.spec.initContainers.map(c, c.image) : []) +
(has(object.spec.ephemeralContainers) ? object.spec.ephemeralContainers.map(c, c.image) : [])
validations:
- expression: >-
variables.images.all(i, i.startsWith('registry.example.com/'))
messageExpression: >-
'images must come from registry.example.com/, rejected: ' +
variables.images.filter(i, !i.startsWith('registry.example.com/')).join(', ')
reason: Forbidden
The images variable concatenates the three lists once, and the validation tests that single list, so the registry check is written in one place. The message uses filter to print only the offending images, which is why rejections name the exact public reference and nothing else. The resource list now includes pods/ephemeralcontainers, explained in the next section. Step 6 shows the effect:
$ kubectl create --dry-run=server -f manifests/pod-good-new.yaml
pod/good-new created (server dry run)
[exit 0]
$ kubectl apply -f manifests/pod-init.yaml
Error from server (Forbidden): error when creating "manifests/pod-init.yaml": pods "init-sneak" is forbidden: ValidatingAdmissionPolicy 'allowed-registries' with binding 'allowed-registries-payments' denied request: images must come from registry.example.com/, rejected: docker.io/library/busybox:1.36
[exit 1]
$ kubectl apply -f manifests/pod-lookalike.yaml
Error from server (Forbidden): error when creating "manifests/pod-lookalike.yaml": pods "lookalike" is forbidden: ValidatingAdmissionPolicy 'allowed-registries' with binding 'allowed-registries-payments' denied request: images must come from registry.example.com/, rejected: registry.example.com.attacker.net/api:1.4.2
[exit 1]
$ kubectl apply -f manifests/pod-hub.yaml
Error from server (Forbidden): error when creating "manifests/pod-hub.yaml": pods "hub" is forbidden: ValidatingAdmissionPolicy 'allowed-registries' with binding 'allowed-registries-payments' denied request: images must come from registry.example.com/, rejected: docker.io/library/nginx:1.27
[exit 1]
Symptom: Right after "improving" the policy, every new Pod in the namespace is rejected with no such key: initContainers.
Cause: An optional list is read without has(), and failurePolicy: Fail turns the resulting CEL error into a rejection.
Fix: Guard each optional list with has() as in policy-v2. Test with kubectl create --dry-run=server (as in step 6) before switching a binding to Deny, so that a broken expression shows up as a dry-run error and not as a namespace-wide outage.
4. Subresources and controllers
A subresource is a path under a resource, such as pods/ephemeralcontainers. The kubectl debug command attaches a temporary debugging container to an existing Pod, and it does so through that subresource. Match rules treat subresources separately. The docs say "*" "matches all resources, but not subresources", while "pods/*" "matches all subresources of pods". A rule listing only pods therefore never sees a debug request, so an engineer could add a public image to a running Pod, and the registry rule would not apply.
Step 7 ran under policy-v2, which lists pods/ephemeralcontainers:
$ kubectl debug -n payments good --image=docker.io/library/busybox:1.36 --target=app -- true
Defaulting debug container name to debugger-qg9h9.
Error from server (Forbidden): pods "good" is forbidden: ValidatingAdmissionPolicy 'allowed-registries' with binding 'allowed-registries-payments' denied request: images must come from registry.example.com/, rejected: docker.io/library/busybox:1.36
[exit 1]
Step 7b re-applied policy-v1, which matches pods only, and repeated the request:
$ kubectl debug -n payments good --image=docker.io/library/busybox:1.36 --target=app -- true
Defaulting debug container name to debugger-tv6zq.
[exit 0]
$ kubectl get pod good -n payments -o jsonpath={.spec.ephemeralContainers[*].image}
docker.io/library/busybox:1.36[exit 0]
The same request that policy-v2 refused exited 0, and the Pod good now carries a public image in its ephemeral containers. Policy-v2 catches it for two reasons: the subresource is in resources, and the variable includes object.spec.ephemeralContainers. Both changes are needed, because matching the subresource without checking the field would let the request through, and checking the field without matching the subresource would never run.
A second gap is the owner of the Pods. In step 4 the Deployment web, which uses docker.io/library/nginx:1.27, was accepted:
$ kubectl apply -f manifests/deploy-hub.yaml
deployment.apps/web created
[exit 0]
The policy matches Pods, not Deployments, so the Deployment object passes. A Deployment's controller later creates Pods through a ReplicaSet, and the policy would evaluate those Pods then. The lab had no controller manager, so it did not test that stage. Any rejection would come when those Pods are created, so the person running kubectl apply would not see it.
Symptom: An engineer runs kubectl debug with a public busybox image in a namespace that rejects public images, and it works.
Cause: The debug container arrives through the pods/ephemeralcontainers subresource, which a rule listing only pods does not match.
Fix: Add pods/ephemeralcontainers to resources and include spec.ephemeralContainers in the checked images, as policy-v2 does.
Putting it together
A working sequence for the exam and for real clusters:
- Write the policy over every container list: containers, init containers and ephemeral containers, with
has()guarding the optional ones. - End the registry string with a slash, so that
registry.example.com.attacker.netdoes not match. - Include
pods/ephemeralcontainersin the match rule. - Create a binding with
[Warn, Audit]and anamespaceSelector, and label the target namespace. - Dry-run a good Pod and each bad Pod with
kubectl create --dry-run=serverand confirm the outcomes. - Switch the binding to
[Deny]. - Remember that the policy does not see Deployments, only the Pods created from them.
In an exam, read the task for the namespace scope and the exact registry string before writing anything. The API server in the lab also served MutatingAdmissionPolicy in admissionregistration.k8s.io/v1 (the step 0 table); it is a separate resource and is not covered here.
Check yourself
pod/hub created with the policy alone.registry.example.com.attacker.net/api:1.4.2 pass policy-v1?startsWith('registry.example.com') compares characters, and the attacker's hostname begins with them. Ending the test string with a slash fixes it.good-new Pod?object.spec.initContainers without has(), so a Pod with no init containers caused the error no such key: initContainers, and failurePolicy: Fail turned that error into a rejection.kubectl debug with a public busybox image succeed under policy-v1 but fail under policy-v2?pods/ephemeralcontainers subresource. Policy-v1 matched only pods, while policy-v2 lists the subresource and checks spec.ephemeralContainers.