Get the Zoom link
Cloud Security Office Hours Banner

Kyverno policies, hands-on

Kyverno judges requests to the Kubernetes API server against policies that are themselves Kubernetes resources. Since version 1.19 deprecated the older format, the logic inside those policies is written in CEL (Common Expression Language). Learn the current policy types with the free kyverno command-line tool, with no cluster, then learn to read the older ClusterPolicy that existing clusters still run.

Jump to the walk-through All how-to guides

·

Time: ~2 hours  ·  Difficulty: Intermediate  ·  You need: the kyverno command-line interface (CLI), from Homebrew or a release download, and a terminal. Network access for step 10 only. No cluster and no cloud account.

How this page was checked: every command, policy and test on this page, including the exercise answers, was run as printed with the Kyverno CLI 1.19.2 (the Homebrew build) on an Apple silicon Mac, in zsh, and the outputs shown are what it printed, less its terminal colors. No cluster was used, because Docker was not available when the page was written, so everything said about a running cluster (webhooks, failure policies, timeouts, background scans and the report objects) comes from Kyverno's 1.19 documentation, its Helm chart and its source code; none of it was observed. Step 10 fetched real signatures from registry.k8s.io and the public Sigstore services. The GitHub Actions workflow in step 11 was not run on GitHub; its commands were run locally. The page was also run with the Kyverno 1.20.0-rc.1 CLI, published on 2 November 2026: it gave the same verdicts for every current policy type, with small differences in how it prints messages and test tables, and refused the legacy ClusterPolicy in step 12 and exercise 3 with an error saying that 1.20 no longer runs or accepts them.

Policies that are Kubernetes resources

Kyverno is an open-source (Apache 2.0) policy engine for Kubernetes, and a graduated project of the Cloud Native Computing Foundation (CNCF) since March 2026. It runs inside the cluster as a handful of controllers. The admission controller is a webhook: before the API server stores an object, it sends Kyverno the create, update or delete request, and Kyverno can reject it, change it, or let it through. Other controllers scan resources that already exist and write what they find into reports, create resources on a trigger, and delete resources on a schedule.

A Kyverno policy is itself a Kubernetes resource: YAML you kubectl apply, review in git, and protect with RBAC (role-based access control) like any other object. Gatekeeper, the admission controller built on the Open Policy Agent (OPA), keeps its policies as Kubernetes resources too, and traditionally writes its checks in Rego, a separate policy language. Kyverno's checks are CEL, the expression language Kubernetes itself uses for admission policies.

There are two generations of Kyverno policy, and the difference decides what you should write today:

So this page writes the current types, and step 12 teaches you to read the legacy ones, which you will meet in running clusters, in blog posts and in older copies of Kyverno's policy library, and will need to migrate. Everything runs in the kyverno CLI, which applies any policy type to YAML files on your laptop at no cost. The CEL itself is covered on the CEL guide; this page assumes no more of it than it explains.

Five quiet ways a Kyverno policy does nothing

A Kyverno policy that never sees the request it was written for raises no error and blocks nothing. These five are what this page is built around, and each is shown running in the step named.

  1. It reports when you meant it to block. A legacy ClusterPolicy with no failure action defaults to Audit: the violating resource is created and the violation goes into a report. A ValidatingPolicy with no validationActions defaults to Deny, so a migration can flip a policy's behavior without anyone deciding to. The CLI reports a failure either way. (Steps 7 and 12.)
  2. The webhook fails open. With failurePolicy: Ignore, a request goes through unchecked whenever Kyverno is down or slower than the webhook timeout, which is 10 seconds by default. A GeneratingPolicy always works that way, and a default install leaves admission requests in kube-system, kube-public, kube-node-lease and Kyverno's own namespace unchecked. (Step 7.)
  3. The match never matches. Write Pods, pod or Pod where Kubernetes says pods, or name the wrong API group, and the policy judges nothing: the CLI prints zeros and exits 0. Add one exclusion to a Pod policy and it quietly stops checking Deployments. (Steps 5 and 6.)
  4. A missing field passes. orValue(true) in CEL and the =() anchor in a legacy pattern both pass a container that never set the field. A check on containers alone misses init and ephemeral containers, and an image check passes every image outside its glob. (Steps 4, 10 and 12.)
  5. Existing resources stay as they are. Admission judges requests, so a new policy affects only what is created or changed after it. What was already running is judged by the background scan, hourly by default, which writes reports and blocks nothing, even when the policy is set to deny. (Step 8.)

On this page

  1. A policy judges only the requests it is sent
  2. Walk-through
  3. Hands-on exercises
  4. Common mistakes
  5. Where next

A policy judges only the requests it is sent

The one idea that makes Kyverno click is that your expression is the last step of a chain, and most failures happen earlier in the chain. In a cluster, an object reaches your policy's check only when every one of these is true:

The CLI skips the whole chain. kyverno apply hands every resource file straight to every policy, so a clean run proves your expression is right and says nothing about whether the policy will see anything. Keep those two questions apart, whether the check is right and whether anything reaches it, and the rest of this page follows.

Walk-through

1. Install the CLI and make a lab

On macOS or Linux with Homebrew:

brew install kyverno
kyverno version
mkdir -p ~/kyverno-lab/policies && cd ~/kyverno-lab

The first line of kyverno version should read Version: 1.19.2 or later in the 1.19 series. Without Homebrew, download kyverno-cli_v1.19.2_ followed by your platform from the 1.19.2 release page, unpack it, and put the kyverno binary on your PATH. The 1.20 release candidate gives the same results for steps 2 to 11, printed slightly differently, and refuses the legacy policies in step 12 and exercise 3, which need a 1.19 CLI.

2. The lab: five Pods and a Deployment

The lab is one rule, "no container may run as root", and six resources that get it right or wrong in different ways. Kubernetes enforces it when a container's securityContext.runAsNonRoot is true, and a container that does not set it inherits the Pod's spec.securityContext.runAsNonRoot. Save this as pods.yaml:

# pods.yaml: five Pods, each getting runAsNonRoot right or wrong its own way
apiVersion: v1
kind: Pod
metadata: {name: web, namespace: shop}
spec:
  containers:
    - name: app
      image: ghcr.io/acme/web:2.1.0
      securityContext: {runAsNonRoot: true}
---
apiVersion: v1
kind: Pod
metadata: {name: root, namespace: shop}
spec:
  containers:
    - name: app
      image: ghcr.io/acme/web:2.1.0
      securityContext: {runAsNonRoot: false}
---
apiVersion: v1
kind: Pod
metadata: {name: unset, namespace: shop}
spec:
  containers:
    - name: app
      image: ghcr.io/acme/web:2.1.0
---
apiVersion: v1
kind: Pod
metadata: {name: inherits, namespace: shop}
spec:
  securityContext: {runAsNonRoot: true}
  containers:
    - name: app
      image: ghcr.io/acme/web:2.1.0
---
apiVersion: v1
kind: Pod
metadata: {name: init, namespace: shop}
spec:
  initContainers:
    - name: setup
      image: ghcr.io/acme/setup:0.9
  containers:
    - name: app
      image: ghcr.io/acme/web:2.1.0
      securityContext: {runAsNonRoot: true}

The right answers: web and inherits are compliant. root asks for root, unset never says, and init has a compliant app container behind an init container, setup, that may run as root. Save this as deploy.yaml, a Deployment whose Pod template never says either:

# deploy.yaml: a Deployment whose Pod template does not set runAsNonRoot
apiVersion: apps/v1
kind: Deployment
metadata: {name: api, namespace: shop}
spec:
  replicas: 2
  selector:
    matchLabels: {app: api}
  template:
    metadata:
      labels: {app: api}
    spec:
      containers:
        - name: api
          image: ghcr.io/acme/api:3.0.2

3. Your first ValidatingPolicy

Save this as policies/run-as-non-root.yaml. It is a reasonable first attempt, and steps 4 to 6 find what is wrong with it:

# policies/run-as-non-root.yaml: first attempt
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
  name: run-as-non-root
spec:
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: [v1]
        operations: [CREATE, UPDATE]
        resources: [pods]
  validations:
    - expression: >-
        object.spec.containers.all(c,
          c.?securityContext.?runAsNonRoot.orValue(false) == true)
      message: every container must set securityContext.runAsNonRoot to true

A ValidatingPolicy is cluster-wide; a NamespacedValidatingPolicy with the same fields applies only inside its own namespace, so a team can own its rules without cluster-admin rights. matchConstraints says which requests the policy wants, in the API server's own terms: the API group ("" is the core group that Pods live in), the version, the operations, and the resource's plural, lowercase name. validations is a list of CEL expressions, each of which must be true. object is the resource in the request. c.?securityContext is CEL's optional field access, which gives "no value" instead of an error when the field is missing, and orValue(false) supplies the value to use in that case. Run it:

kyverno apply policies/run-as-non-root.yaml --resource pods.yaml
echo $?
Applying 1 policy rule(s) to 5 resource(s)...
policy run-as-non-root -> resource shop/Pod/root failed:
1 -  every container must set securityContext.runAsNonRoot to true
policy run-as-non-root -> resource shop/Pod/unset failed:
1 -  every container must set securityContext.runAsNonRoot to true
policy run-as-non-root -> resource shop/Pod/inherits failed:
1 -  every container must set securityContext.runAsNonRoot to true

pass: 2, fail: 3, warn: 0, error: 0, skip: 0
1

Each failing resource is named with the message, and the summary counts results: pass and fail are verdicts, error means the expression itself failed to evaluate, and skip means a condition or exception excluded the resource. The exit status is 1 because something failed, which is what makes the command usable as a gate in CI (continuous integration). Two of the five verdicts are wrong: inherits is compliant and failed, and init is not and passed.

4. The field that is not there

Start with the mistake that looks like a fix. If inherits fails because its container says nothing, it is tempting to treat "says nothing" as compliant:

sed 's/orValue(false)/orValue(true)/' policies/run-as-non-root.yaml > lenient.yaml
kyverno apply lenient.yaml --resource pods.yaml
Applying 1 policy rule(s) to 5 resource(s)...
policy run-as-non-root -> resource shop/Pod/root failed:
1 -  every container must set securityContext.runAsNonRoot to true

pass: 4, fail: 1, warn: 0, error: 0, skip: 0

inherits passes now, for the wrong reason, and so does unset, which may well run as root: a container that does not set runAsNonRoot runs as whatever user its image names, and many images name root. The default you give a missing field is the policy's verdict on everyone who forgot, so it belongs on the strict side. The right fix follows Kubernetes' own rule: use the container's value if it has one, otherwise the Pod's, otherwise fail. Then do the same for every kind of container a Pod can have. Replace policies/run-as-non-root.yaml with:

# policies/run-as-non-root.yaml: every container, with Pod-level inheritance
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
  name: run-as-non-root
spec:
  validationActions: [Deny]
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: [v1]
        operations: [CREATE, UPDATE]
        resources: [pods]
  variables:
    - name: containers
      expression: >-
        object.spec.containers +
        object.spec.?initContainers.orValue([]) +
        object.spec.?ephemeralContainers.orValue([])
    - name: podDefault
      expression: object.spec.?securityContext.?runAsNonRoot.orValue(false)
    - name: offenders
      expression: >-
        variables.containers.filter(c,
          c.?securityContext.?runAsNonRoot.orValue(variables.podDefault) != true)
  validations:
    - expression: size(variables.offenders) == 0
      messageExpression: >-
        'may run as root: ' + variables.offenders.map(c, c.name).join(', ')
kyverno apply policies/run-as-non-root.yaml --resource pods.yaml
Applying 1 policy rule(s) to 5 resource(s)...
policy run-as-non-root -> resource shop/Pod/root failed:
1 -  may run as root: app
policy run-as-non-root -> resource shop/Pod/unset failed:
1 -  may run as root: app
policy run-as-non-root -> resource shop/Pod/init failed:
1 -  may run as root: setup

pass: 2, fail: 3, warn: 0, error: 0, skip: 0

All five verdicts are right. variables are named CEL expressions that later expressions read as variables.name, which keeps the validation itself one short line. messageExpression builds the message with CEL, so the denial names the container to fix; a denial that says what to change saves a support ticket every time it fires. Kyverno's own Pod Security policies do the same container arithmetic, because a check on containers alone is the commonest way to miss an init container and every container that kubectl debug adds.

5. The match that never matches

resources wants the plural, lowercase name that the API server uses in URLs, which is the NAME column of kubectl api-resources. Write the kind instead and see what happens:

sed 's/resources: \[pods\]/resources: [Pods]/' policies/run-as-non-root.yaml > typo.yaml
kyverno apply typo.yaml --resource pods.yaml
echo $?
Applying 1 policy rule(s) to 5 resource(s)...

pass: 0, fail: 0, warn: 0, error: 0, skip: 0
0

Five resources, no verdicts, exit status 0. [pod], [Pod], apiGroups: [core] and apiVersions: [v1beta1] all do the same. Nothing in the run tells you the policy is dead, and a CI job that checks only the exit status goes green. --warn-no-pass turns "nothing passed" into a separate exit status:

kyverno apply typo.yaml --resource pods.yaml --warn-exit-code 3 --warn-no-pass
echo $?
Applying 1 policy rule(s) to 5 resource(s)...

pass: 0, fail: 0, warn: 0, error: 0, skip: 0
exit as no objects satisfied policy
3

That catches a run where nothing passes at all. It counts across the whole run, though: give kyverno apply a folder of policies in which one is dead and the others pass something, and it exits 0. Catching one dead policy among many needs a statement of what each policy should find, which is what kyverno test in step 11 is for.

Besides resourceRules, matchConstraints takes a namespaceSelector (namespace labels), an objectSelector (the resource's labels) and excludeResourceRules, and the policy can add matchConditions, CEL expressions that must all be true for the policy to apply. Scope with them sparingly: step 6 shows what one of them costs.

6. Autogen: one Pod policy, every Pod controller

Most Pods are created by a controller such as a Deployment. Run the policy against the lab's Deployment:

kyverno apply policies/run-as-non-root.yaml --resource deploy.yaml
Applying 1 policy rule(s) to 1 resource(s)...
policy run-as-non-root -> resource shop/Deployment/api failed:
1 -  may run as root: api

pass: 0, fail: 1, warn: 0, error: 0, skip: 0

The policy matches only Pods, yet it judged the Deployment. This is autogen: for a policy that matches Pods, Kyverno generates variants for DaemonSets, Deployments, ReplicaSets, StatefulSets, Jobs and CronJobs, rewriting object.spec to the Pod template's path in each. That is why the variables in step 4 still worked. Without autogen, the Deployment would be accepted and its ReplicaSet would then fail to create Pods, so kubectl apply would succeed and the trouble would show up later, as a rollout that never completes and a ReplicaFailure condition on the Deployment.

Autogen runs only when the match is exactly one rule for core v1 pods, with no objectSelector, no excludeResourceRules and no resourceNames. Suppose you widen the policy to cover the ephemeral containers that kubectl debug adds, which arrive as an update to the pods/ephemeralcontainers subresource. Save this as ephemeral.yaml:

# ephemeral.yaml: the same policy, also matching kubectl debug
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
  name: run-as-non-root-wide
spec:
  validationActions: [Deny]
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: [v1]
        operations: [CREATE, UPDATE]
        resources: [pods]
      - apiGroups: [""]
        apiVersions: [v1]
        operations: [UPDATE]
        resources: [pods/ephemeralcontainers]
  variables:
    - name: containers
      expression: >-
        object.spec.containers +
        object.spec.?initContainers.orValue([]) +
        object.spec.?ephemeralContainers.orValue([])
    - name: podDefault
      expression: object.spec.?securityContext.?runAsNonRoot.orValue(false)
    - name: offenders
      expression: >-
        variables.containers.filter(c,
          c.?securityContext.?runAsNonRoot.orValue(variables.podDefault) != true)
  validations:
    - expression: size(variables.offenders) == 0
      messageExpression: >-
        'may run as root: ' + variables.offenders.map(c, c.name).join(', ')
kyverno apply ephemeral.yaml --resource deploy.yaml
Applying 1 policy rule(s) to 1 resource(s)...

pass: 0, fail: 0, warn: 0, error: 0, skip: 0

The second rule switched autogen off, and the Deployment is no longer judged at all. Two ways to keep coverage: keep each Pod policy's match to plain pods and put the ephemeral-container check in a policy of its own, or do what Kyverno's own Pod Security policies do and list every controller in resourceRules, finding the Pod spec with a variable. Either way, put the exemptions you will be asked for ("except the debug Pod") in a PolicyException, which leaves the match, and so autogen, alone.

7. Deny, Audit, Warn, and failing open

validationActions says what a failed check does in a cluster: Deny rejects the request, Audit admits it and records the failure in a report, and Warn admits it and sends the client a warning that kubectl prints. Audit and Warn can be combined. Left out, it is Deny. Now change it and watch what the CLI says:

sed 's/validationActions: \[Deny\]/validationActions: [Audit]/' policies/run-as-non-root.yaml > audit.yaml
kyverno apply audit.yaml --resource pods.yaml
echo $?
Applying 1 policy rule(s) to 5 resource(s)...
policy run-as-non-root -> resource shop/Pod/root failed:
1 -  may run as root: app
policy run-as-non-root -> resource shop/Pod/unset failed:
1 -  may run as root: app
policy run-as-non-root -> resource shop/Pod/init failed:
1 -  may run as root: setup

pass: 2, fail: 3, warn: 0, error: 0, skip: 0
1

Exactly the same output as the denying version. The CLI reports whether each resource passes the check, and has no notion of what a cluster would do with a failure. Its --audit-warn flag changes each failure's label to failed as audit warning, but in 1.19.2 it does that for every ValidatingPolicy, including ones set to Deny, and leaves the counts and the exit status as they were, so it cannot tell the two apart for you either. Whether a policy blocks is a line in its YAML, and the only place to check it is there.

The rest of what decides a policy's behavior in a cluster is settings that a run on your laptop never exercises. These are the defaults in Kyverno 1.19:

Setting Default, and what it means
validationActions [Deny]: a failed check rejects the request. A legacy ClusterPolicy defaults the other way, to Audit (step 12).
failurePolicy Fail: if Kyverno cannot answer, or the expression errors, the request is rejected. Ignore lets it through unchecked. A GeneratingPolicy always uses Ignore.
timeoutSeconds 10, set under webhookConfiguration: how long the API server waits for Kyverno (1 to 30 seconds) before the failure policy decides.
evaluation.admission.enabled true: the policy judges requests at admission.
evaluation.background.enabled true: the policy also judges existing resources in the background scan (step 8).

Two settings outside the policy matter as much. The admission controller's --forceFailurePolicyIgnore flag (Helm value features.forceFailurePolicyIgnore.enabled, off by default) turns every policy's failure policy into Ignore, so the cluster fails open whenever Kyverno is unavailable. And the default install excludes namespaces: its webhooks skip kube-system and Kyverno's own namespace, and its resource filters also skip kube-public, kube-node-lease and kinds such as Event, Node and ReplicaSet. A policy can match everything and still never be asked about a Pod in kube-system at admission. The background scan of step 8 does report on those namespaces, because by default it ignores the resource filters.

The exclusions exist for a reason. With failurePolicy: Fail and Kyverno down, every matching request is rejected, and if that included the system Pods that would bring Kyverno back, the cluster could not recover on its own. Kyverno's troubleshooting guide covers that case by deleting its webhook configurations so the API server stops calling it, and they are recreated when Kyverno comes back. The safe pattern is Fail plus at least three admission-controller replicas, with the exclusions left alone, rather than Ignore.

8. Existing resources, background scans and reports

Admission sees requests, so on the day you apply a new denying policy, nothing already running changes. Kyverno's answer for existing resources is the background scan: the reports controller re-checks matching resources periodically, every hour by default, and records the results. It never blocks or deletes anything, whatever validationActions says. Ask the CLI for the same kind of report:

kyverno apply policies/run-as-non-root.yaml --resource pods.yaml --policy-report

After the usual summary it prints a report with one entry per verdict. The first is:

apiVersion: openreports.io/v1alpha1
kind: ClusterReport
metadata:
  name: merged
results:
- message: 'may run as root: app'
  policy: run-as-non-root
  properties:
    cel.validationIndex: "0"
    process: background scan
  resources:
  - apiVersion: v1
    kind: Pod
    name: root
    namespace: shop
  result: fail

and it ends with the same counts as the summary. In a cluster, Kyverno 1.19 writes these by default as PolicyReport objects (one per namespaced resource, in its namespace) and ClusterPolicyReport objects (for cluster-scoped resources), in the wgpolicyk8s.io/v1alpha2 API group; the openreports.io format the CLI prints is an alpha alternative you can switch the cluster to. Either way, a report is where you find out what a new policy would break before you let it deny anything, and where you find the resources it never will.

Results from admission also land in reports, including for Audit, which is the point of Audit. Set evaluation.background.enabled: false only for a policy that reads something that exists only in an admission request, such as who made it.

9. Mutate and generate

A MutatingPolicy changes the resource on its way in. This one gives every new Pod that does not set one the RuntimeDefault seccomp profile, the container runtime's standard filter on which system calls a container may make. Save it as default-seccomp.yaml:

# default-seccomp.yaml: give Pods the RuntimeDefault seccomp profile if they set none
apiVersion: policies.kyverno.io/v1
kind: MutatingPolicy
metadata:
  name: default-seccomp
spec:
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: [v1]
        operations: [CREATE]
        resources: [pods]
  matchConditions:
    - name: no-seccomp-profile-yet
      expression: >-
        !has(object.spec.securityContext) ||
        !has(object.spec.securityContext.seccompProfile)
  mutations:
    - patchType: ApplyConfiguration
      applyConfiguration:
        expression: >-
          Object{
            spec: Object.spec{
              securityContext: Object.spec.securityContext{
                seccompProfile: Object.spec.securityContext.seccompProfile{
                  type: "RuntimeDefault"
                }
              }
            }
          }
kyverno apply default-seccomp.yaml --resource pods.yaml

It prints each mutated Pod. This is unset:

policy default-seccomp applied to shop/Pod/unset:
apiVersion: v1
kind: Pod
metadata:
    name: unset
    namespace: shop
spec:
    containers:
        - image: ghcr.io/acme/web:2.1.0
          name: app
    securityContext:
        seccompProfile:
            type: RuntimeDefault

The ApplyConfiguration patch is an object with only the fields to set, merged the way server-side apply merges; a JSONPatch patch type is there for edits that a merge cannot express. The matchConditions entry is what keeps the policy from overwriting a profile someone chose. In 1.19.2 the CLI prints each mutated Pod twice and counts each twice in its summary line; add -t for a table with one row per Pod. In a cluster, mutation happens before validation, so a ValidatingPolicy sees the Pod after this policy has changed it.

A GeneratingPolicy creates other resources when a trigger resource appears. A classic one gives every new namespace a NetworkPolicy that denies all incoming traffic until someone allows it. Save these as default-deny.yaml and namespace.yaml:

# default-deny.yaml: every new namespace gets a deny-all-ingress NetworkPolicy
apiVersion: policies.kyverno.io/v1
kind: GeneratingPolicy
metadata:
  name: default-deny-ingress
spec:
  evaluation:
    synchronize:
      enabled: true
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: [v1]
        operations: [CREATE]
        resources: [namespaces]
  variables:
    - name: ns
      expression: object.metadata.name
    - name: netpol
      expression: >-
        [{
          "apiVersion": dyn("networking.k8s.io/v1"),
          "kind": dyn("NetworkPolicy"),
          "metadata": dyn({"name": "default-deny-ingress", "namespace": variables.ns}),
          "spec": dyn({"podSelector": {}, "policyTypes": ["Ingress"]})
        }]
  generate:
    - expression: generator.Apply(variables.ns, variables.netpol)
# namespace.yaml: the trigger
apiVersion: v1
kind: Namespace
metadata:
  name: payments
kyverno apply default-deny.yaml --resource namespace.yaml
Applying 1 policy rule(s) to 1 resource(s)...

policy default-deny-ingress applied to /Namespace/payments:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
    name: default-deny-ingress
    namespace: payments
spec:
    podSelector: {}
    policyTypes:
        - Ingress

---


Generate:
Generation completed successfully.
pass: 1, fail: 0, warn: 0, error: 0, skip: 0

generator.Apply takes the namespace to create in and a list of resources, and dyn() tells CEL's type checker to accept a map of mixed value types. synchronize keeps the NetworkPolicy matching the policy, and puts it back if someone deletes it. Generation is not part of the admission decision: the namespace is admitted, and Kyverno's background controller then creates the NetworkPolicy from a queued request, retrying a failed one three times before giving up. If the background controller is down, or lacks the RBAC permissions to create the kind you generate, you get the namespace without the NetworkPolicy, and nothing rejects anything.

10. Verify image signatures

An ImageValidatingPolicy checks container images rather than resource fields: who signed them, and what attestations (signed statements about how an image was built, such as an SBOM (software bill of materials) or provenance) they carry. It works with Sigstore's cosign signatures and with Notary. Kubernetes signs its own release images with Sigstore's "keyless" signing, which ties each signature to an OpenID Connect identity rather than a long-lived key, so its images make a real test. This step reaches the network: it reads signatures from registry.k8s.io and checks them against Sigstore's public services. Save signed-images.yaml and images.yaml:

# signed-images.yaml: images from registry.k8s.io must be signed by the Kubernetes release team
apiVersion: policies.kyverno.io/v1
kind: ImageValidatingPolicy
metadata:
  name: k8s-images-signed
spec:
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: [v1]
        operations: [CREATE]
        resources: [pods]
  matchImageReferences:
    - glob: "registry.k8s.io/*"
  attestors:
    - name: k8s
      cosign:
        keyless:
          identities:
            - issuer: https://accounts.google.com
              subject: krel-trust@k8s-releng-prod.iam.gserviceaccount.com
  validations:
    - expression: >-
        images.containers.map(image,
          verifyImageSignatures(image, [attestors.k8s])).all(n, n > 0)
      message: registry.k8s.io images must be signed by the Kubernetes release team
# images.yaml: one image the policy covers and one it does not
apiVersion: v1
kind: Pod
metadata: {name: pause, namespace: shop}
spec:
  containers:
    - name: pause
      image: registry.k8s.io/pause:3.10@sha256:ee6521f290b2168b6e0935a181d4cff9be1ac3f505666ef0e3c98fae8199917a
---
apiVersion: v1
kind: Pod
metadata: {name: toolbox, namespace: shop}
spec:
  containers:
    - name: tools
      image: docker.io/library/busybox:1.37
kyverno apply signed-images.yaml --resource images.yaml
Applying 1 policy rule(s) to 2 resource(s)...

pass: 2, fail: 0, warn: 0, error: 0, skip: 0

Two passes, and only one of them means anything. pause passed because its signature checked out against the named identity. toolbox passed because busybox is outside matchImageReferences, and an image outside the globs is not checked at all. A policy scoped to your own registry says nothing about images pulled from anywhere else, so pair it with a policy that allows only the registries you trust. Now break the identity to prove the check can fail:

sed 's/krel-trust@/someone-else@/' signed-images.yaml > wrong-signer.yaml
kyverno apply wrong-signer.yaml --resource images.yaml
Applying 1 policy rule(s) to 2 resource(s)...
policy k8s-images-signed -> resource shop/Pod/pause failed:
1 - k8s-images-signed registry.k8s.io images must be signed by the Kubernetes release team

pass: 1, fail: 1, warn: 0, error: 0, skip: 0

The pause image is referenced by digest because of three defaults under validationConfigurations: mutateDigest rewrites a tag to the digest it currently points at, verifyDigest requires a digest, and required requires that every matched image was verified. The CLI applies the last two but rewrites nothing, so on your laptop a tag-only reference fails with does not have a digest. attestations and verifyAttestationSignatures() extend the same policy to signed build provenance and software bills of materials; the ImageValidatingPolicy documentation has the full set.

11. Test policies in CI

kyverno apply tells you what happened. kyverno test compares what happened with what you said should happen, per policy and per resource, which is the only way to notice a policy that quietly stopped matching. Save this as tests/run-as-non-root/kyverno-test.yaml (paths in it are relative to the file):

# tests/run-as-non-root/kyverno-test.yaml: what the policy must find, both ways
apiVersion: cli.kyverno.io/v1alpha1
kind: Test
metadata:
  name: run-as-non-root
policies:
  - ../../policies/run-as-non-root.yaml
resources:
  - ../../pods.yaml
  - ../../deploy.yaml
results:
  - policy: run-as-non-root
    isValidatingPolicy: true
    kind: Pod
    resources: [web, inherits]
    result: pass
  - policy: run-as-non-root
    isValidatingPolicy: true
    kind: Pod
    resources: [root, unset, init]
    result: fail
  - policy: run-as-non-root
    isValidatingPolicy: true
    kind: Deployment
    resources: [api]
    result: fail
kyverno test tests/
Loading test  ( tests/run-as-non-root/kyverno-test.yaml ) ...
  Loading values/variables ...
  Loading policies ...
  Loading resources ...
  Loading exceptions ...
  Applying 1 policy to 6 resources with 0 exceptions ...
  Checking results ...

│────│─────────────────│──────│─────────────────────────────│────────│────────│
│ ID │ POLICY          │ RULE │ RESOURCE                    │ RESULT │ REASON │
│────│─────────────────│──────│─────────────────────────────│────────│────────│
│ 1  │ run-as-non-root │      │ v1/Pod/shop/web             │ Pass   │ Ok     │
│ 2  │ run-as-non-root │      │ v1/Pod/shop/inherits        │ Pass   │ Ok     │
│ 3  │ run-as-non-root │      │ v1/Pod/shop/root            │ Pass   │ Ok     │
│ 4  │ run-as-non-root │      │ v1/Pod/shop/unset           │ Pass   │ Ok     │
│ 5  │ run-as-non-root │      │ v1/Pod/shop/init            │ Pass   │ Ok     │
│ 6  │ run-as-non-root │      │ apps/v1/Deployment/shop/api │ Pass   │ Ok     │
│────│─────────────────│──────│─────────────────────────────│────────│────────│


Test Summary: 6 tests passed and 0 tests failed

In this table Pass means "the result was what you expected", so root passing the test means it failed the policy, as it should. A test that cannot fail proves nothing, so break the policy on purpose, run the test, and put the policy back:

cp policies/run-as-non-root.yaml keep.yaml
sed 's/orValue(variables.podDefault)/orValue(true)/' keep.yaml > policies/run-as-non-root.yaml
kyverno test tests/
echo $?
mv keep.yaml policies/run-as-non-root.yaml
│ 4  │ run-as-non-root │      │ v1/Pod/shop/unset           │ Fail   │ Want fail, got pass │
│ 5  │ run-as-non-root │      │ v1/Pod/shop/init            │ Fail   │ Want fail, got pass │
│ 6  │ run-as-non-root │      │ apps/v1/Deployment/shop/api │ Fail   │ Want fail, got pass │

Test Summary: 3 tests passed and 3 tests failed

Error: 3 tests failed
1

Those are the rows that changed: a one-word edit let through every resource that relies on the default, the Deployment included. A test also fails a dead match: with the [Pods] typo from step 5, every row reads Not found and the test exits 1. Two flags close the remaining gaps. kyverno test on a folder that exists but holds no kyverno-test.yaml prints No test yamls available and exits 0, so tests that were moved or renamed stop running without failing CI; --require-tests makes that an error. A path that does not exist at all is an error either way. --warnings-as-errors fails the run if any policy uses a deprecated type, which catches a ClusterPolicy added to the repository after you migrated. A GitHub Actions job that runs both checks on every pull request:

# .github/workflows/kyverno.yaml
name: kyverno
on: [pull_request]
permissions:
  contents: read
jobs:
  policies:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
        with:
          persist-credentials: false
      - uses: kyverno/action-install-cli@fcee92fca5c883169ef9927acf543e0b5fc58289  # v0.2.0
        with:
          release: v1.19.2
      - run: kyverno test tests/ --require-tests --warnings-as-errors
      - run: kyverno apply policies/ --resource manifests/ --warnings-as-errors --warn-exit-code 3 --warn-no-pass

The first run proves the policies behave; the second judges the manifests in the pull request against them, with manifests/ standing for wherever your repository keeps them. It carries --warn-no-pass because kyverno apply does not treat a missing policy path as an error: point it at a mistyped folder and it prints Applying 0 policy rule(s) and exits 0. Both actions are pinned to a commit rather than a tag, so a moved tag cannot change what runs. Pin the CLI version too, and change it on purpose: running your tests against the next release before upgrading the cluster is the cheapest upgrade check there is.

12. Reading a legacy ClusterPolicy

Here is the check from step 4 written the pre-1.19 way, plus a second rule that denies privileged containers. Save legacy.yaml, and privileged.yaml for the second rule to catch:

# legacy.yaml: two rules in the kyverno.io/v1 format that 1.20 stops running
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: legacy-pod-rules
spec:
  rules:
    - name: run-as-non-root
      match:
        any:
          - resources:
              kinds: [Pod]
      exclude:
        any:
          - resources:
              namespaces: [kube-system]
      validate:
        message: every container must set securityContext.runAsNonRoot to true
        pattern:
          spec:
            containers:
              - =(securityContext):
                  =(runAsNonRoot): true
    - name: no-privileged
      match:
        any:
          - resources:
              kinds: [Pod]
      validate:
        message: "privileged containers: {{ request.object.spec.containers[?securityContext.privileged].name }}"
        deny:
          conditions:
            any:
              - key: "{{ request.object.spec.containers[?securityContext.privileged == `true`] | length(@) }}"
                operator: GreaterThan
                value: 0
# privileged.yaml: one compliant container, one privileged
apiVersion: v1
kind: Pod
metadata: {name: debug, namespace: shop}
spec:
  containers:
    - name: app
      image: ghcr.io/acme/web:2.1.0
      securityContext: {runAsNonRoot: true}
    - name: shell
      image: ghcr.io/acme/debug:0.3
      securityContext: {runAsNonRoot: true, privileged: true}
kyverno apply legacy.yaml --resource pods.yaml --resource privileged.yaml
Warning: legacy.yaml: kyverno.io/v1 ClusterPolicy is deprecated and will be removed in a future release; migrate to ValidatingPolicy, MutatingPolicy, GeneratingPolicy or ImageValidatingPolicy (policies.kyverno.io), see https://kyverno.io/docs/guides/migration-to-cel/

Applying 6 policy rule(s) to 6 resource(s)...
policy legacy-pod-rules -> resource shop/Pod/root failed:
1 - run-as-non-root validation error: every container must set securityContext.runAsNonRoot to true. rule run-as-non-root failed at path /spec/containers/0/securityContext/runAsNonRoot/
policy legacy-pod-rules -> resource shop/Pod/debug failed:
1 - no-privileged privileged containers: ["shell"]

pass: 10, fail: 2, warn: 0, error: 0, skip: 0

Read it from the top. A ClusterPolicy applies cluster-wide and a Policy only in its own namespace. Each rule has a match and an optional exclude, each with any (at least one entry must apply) or all (every entry must), and each entry filters on kinds, names, namespaces, label selectors, or who made the request. Kinds are written as kinds, Pod, optionally as group/version/Kind; misspell one and the CLI reports zeros, just as in step 5. "6 policy rule(s)" is two rules plus autogen's variants, which a legacy policy gets for Pod rules by default.

pattern is an overlay: the resource must look like the YAML under it, and a plain field in the pattern must exist in the resource. Values can be wildcards (?* means "any non-empty value") or comparisons (>3, !*:latest). Anchors change what a field means:

Look at the results with that in mind. unset and init passed. Both =() anchors are equality anchors, so a container with no securityContext satisfies the pattern, which is exactly the lenient default of step 4 in another syntax. Remove the anchors and unset fails, but so does inherits, and the pattern still sees only containers.

deny is the other way to validate: the request is denied when the conditions hold. The {{ }} are variables, written in JMESPath, a query language for JSON (JavaScript Object Notation) that is also built into the AWS CLI, and that the jq and JMESPath guide teaches. request.object is the resource; containers[?securityContext.privileged == `true`] is a filter projection keeping the privileged containers, and | length(@) counts them. The backticks around true make it a JSON literal, and exercise 3 shows what happens with quotes instead. In CEL the whole rule is one expression, !object.spec.containers.exists(c, c.?securityContext.?privileged.orValue(false)), with the logic turned around because a deny condition says what is forbidden and a validation says what must be true.

Now check the enforcement mode. Neither rule sets failureAction, so both fall back to the policy-wide default, and --audit-warn shows what that is:

kyverno apply legacy.yaml --resource pods.yaml --resource privileged.yaml --audit-warn
echo $?
pass: 10, fail: 0, warn: 2, error: 0, skip: 0
0

The two failures became warnings and the exit status 0: both rules are in Audit mode. A legacy policy blocks only where a rule says failureAction: Enforce, the deprecated policy-wide validationFailureAction says Enforce, or a per-namespace override (failureActionOverrides) does, so a policy written to block that never says so only ever reports. For a legacy policy that is in one mode throughout, --audit-warn does show the difference, unlike for the new types: with Enforce the failures stay failures. Legacy validate rules also default to allowExistingViolations: true, which lets an update through to a resource that already violated the rule before the update.

To migrate, Kyverno's migration guide maps every field. The ones that matter most:

Legacy ClusterPolicy Current types
One policy, many rules of different kinds One policy type per action: ValidatingPolicy, MutatingPolicy, GeneratingPolicy, ImageValidatingPolicy, DeletingPolicy
match on kinds; exclude matchConstraints.resourceRules on group, version and plural resource; excludeResourceRules; matchConditions
pattern, anyPattern, foreach CEL in validations, using all(), exists() and optional fields
deny conditions CEL in validations, with the logic inverted
context and JMESPath variables variables, plus Kyverno's CEL libraries for lookups and API calls
failureAction, default Audit validationActions, default Deny
allowExistingViolations, failureActionOverrides Not supported; Kyverno points to PolicyExceptions instead

The default in the last row but one is the trap: a legacy policy that was quietly auditing becomes, translated field for field, a policy that denies. Decide which you want and write it down in validationActions. Then run the old and new policies side by side with the CLI against the same resources, as this step and step 4 did, before deleting anything. Kyverno's documentation also describes --warnings-as-errors and the kyverno_deprecated_api_requests_total metric for finding legacy policies that are still in use.

Hands-on exercises

Run these in ~/kyverno-lab, with the files from the walk-through.

Exercise 1

This policy is meant to cap Deployments at 10 replicas. Save it as replicas.yaml, make a 50-replica copy of the Deployment named big with sed 's/replicas: 2/replicas: 50/; s/name: api,/name: big,/' deploy.yaml > big.yaml, and run the policy against both. Why does nothing fail, and what are the two fixes?

# replicas.yaml: meant to cap Deployments at 10 replicas
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
  name: max-replicas
spec:
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: [v1]
        operations: [CREATE, UPDATE]
        resources: [Deployments]
  validations:
    - expression: object.spec.replicas <= 10
      message: at most 10 replicas
Show the answer

Deployments live in the apps API group, not the core group "", and the resource name is deployments. Either mistake alone makes the policy match nothing. Fixed, as replicas-fixed.yaml:

# replicas-fixed.yaml: apps group, plural lowercase resource
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
  name: max-replicas
spec:
  matchConstraints:
    resourceRules:
      - apiGroups: [apps]
        apiVersions: [v1]
        operations: [CREATE, UPDATE]
        resources: [deployments]
  validations:
    - expression: object.spec.?replicas.orValue(1) <= 10
      message: at most 10 replicas
sed 's/replicas: 2/replicas: 50/; s/name: api,/name: big,/' deploy.yaml > big.yaml
kyverno apply replicas.yaml --resource deploy.yaml --resource big.yaml --warn-exit-code 3 --warn-no-pass
echo $?
kyverno apply replicas-fixed.yaml --resource deploy.yaml --resource big.yaml
Applying 1 policy rule(s) to 2 resource(s)...

pass: 0, fail: 0, warn: 0, error: 0, skip: 0
exit as no objects satisfied policy
3

Applying 1 policy rule(s) to 2 resource(s)...
policy max-replicas -> resource shop/Deployment/big failed:
1 -  at most 10 replicas

pass: 1, fail: 1, warn: 0, error: 0, skip: 0

The copy gets its own name because the 1.19.2 CLI crashes, with panic: deployments.apps "api" already exists, when two resources in one run share a kind, namespace and name, which is easy to hit with a base manifest and an overlay of it. The fixed policy also reads replicas optionally: a Deployment manifest may leave it out, and the API server fills in 1 when it stores the object, but a file on your laptop has no such default, and reading a missing field in CEL is an error.

Exercise 2

Write a policy that rejects Pods running a latest image. Test it against these four Pods, saved as tags.yaml. Try !c.image.endsWith(':latest') first and see which Pods it lets through.

# tags.yaml: four ways to name an image
apiVersion: v1
kind: Pod
metadata: {name: pinned, namespace: shop}
spec:
  containers: [{name: app, image: "ghcr.io/acme/web:2.1.0"}]
---
apiVersion: v1
kind: Pod
metadata: {name: latest, namespace: shop}
spec:
  containers: [{name: app, image: "nginx:latest"}]
---
apiVersion: v1
kind: Pod
metadata: {name: untagged, namespace: shop}
spec:
  containers: [{name: app, image: "nginx"}]
---
apiVersion: v1
kind: Pod
metadata: {name: local, namespace: shop}
spec:
  containers: [{name: app, image: "registry.local:5000/tools"}]
Show the answer

The string check catches only latest. An image with no tag means latest too, so untagged and local are just as unpinned, and the colon in registry.local:5000 is a port, which defeats most hand-written string checks. Kyverno's CEL image library parses the reference the way a container runtime does. Saved as policies/no-latest.yaml:

# policies/no-latest.yaml: parse the image reference instead of matching strings
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
  name: no-latest
spec:
  validationActions: [Deny]
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: [v1]
        operations: [CREATE, UPDATE]
        resources: [pods]
  validations:
    - expression: >-
        object.spec.containers.all(c, image(c.image).tag() != 'latest')
      messageExpression: >-
        'unpinned images: ' + object.spec.containers
          .filter(c, image(c.image).tag() == 'latest')
          .map(c, c.image).join(', ')
kyverno apply policies/no-latest.yaml --resource tags.yaml
Applying 1 policy rule(s) to 4 resource(s)...
policy no-latest -> resource shop/Pod/latest failed:
1 -  unpinned images: nginx:latest
policy no-latest -> resource shop/Pod/untagged failed:
1 -  unpinned images: nginx
policy no-latest -> resource shop/Pod/local failed:
1 -  unpinned images: registry.local:5000/tools

pass: 1, fail: 3, warn: 0, error: 0, skip: 0

image(c.image).tag() reports latest for all three. A stricter version requires a digest instead, with image(c.image).containsDigest(), which is the only reference that cannot change under you. Like step 3, this checks containers only; use step 4's variable to cover the rest.

Exercise 3

In legacy.yaml, change == `true` in the no-privileged rule to == 'true', which looks like the same thing. Predict what happens to debug, run it, then write the rule as a ValidatingPolicy and confirm the CEL version catches debug and passes the five Pods in pods.yaml.

Show the answer
sed "s/privileged == \`true\`/privileged == 'true'/" legacy.yaml > legacy-quoted.yaml
kyverno apply legacy-quoted.yaml --resource privileged.yaml
Applying 6 policy rule(s) to 1 resource(s)...

pass: 2, fail: 0, warn: 0, error: 0, skip: 0

debug passes both rules. In JMESPath, single quotes make a string, so the filter compares the boolean true in the Pod with the string "true", finds no match, and the count is 0. The rule still loads, still runs, and denies nothing. The jq and JMESPath guide has the same trap in its JMESPath step. CEL does not protect you from the same slip. Kyverno reads a Pod's fields as dynamic values, so c.?securityContext.?privileged.orValue(false) == 'true' is quietly false for every container too; CEL rejects the comparison only when it can see both types before it runs. Drop the comparison and use the boolean itself:

# no-privileged.yaml: the legacy deny rule as a ValidatingPolicy
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
  name: no-privileged
spec:
  validationActions: [Deny]
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: [v1]
        operations: [CREATE, UPDATE]
        resources: [pods]
  validations:
    - expression: >-
        !object.spec.containers.exists(c,
          c.?securityContext.?privileged.orValue(false))
      messageExpression: >-
        'privileged containers: ' + object.spec.containers
          .filter(c, c.?securityContext.?privileged.orValue(false))
          .map(c, c.name).join(', ')
kyverno apply no-privileged.yaml --resource privileged.yaml --resource pods.yaml
Applying 1 policy rule(s) to 6 resource(s)...
policy no-privileged -> resource shop/Pod/debug failed:
1 -  privileged containers: shell

pass: 5, fail: 1, warn: 0, error: 0, skip: 0

Here orValue(false) is the right default: a container that does not ask for privileged mode does not get it.

Exercise 4

Write tests/no-latest/kyverno-test.yaml for your exercise 2 policy, run kyverno test tests/, then prove the test can fail by putting the endsWith version in place.

Show the answer
# tests/no-latest/kyverno-test.yaml
apiVersion: cli.kyverno.io/v1alpha1
kind: Test
metadata:
  name: no-latest
policies:
  - ../../policies/no-latest.yaml
resources:
  - ../../tags.yaml
results:
  - policy: no-latest
    isValidatingPolicy: true
    kind: Pod
    resources: [pinned]
    result: pass
  - policy: no-latest
    isValidatingPolicy: true
    kind: Pod
    resources: [latest, untagged, local]
    result: fail
kyverno test tests/
cp policies/no-latest.yaml keep.yaml
sed 's/image(c.image).tag() != .latest./!c.image.endsWith(":latest")/' keep.yaml > policies/no-latest.yaml
kyverno test tests/ --fail-only
echo $?
mv keep.yaml policies/no-latest.yaml

The first run passes all ten rows, four for no-latest and six for run-as-non-root, because kyverno test tests/ runs every test file under the folder. With the string check in place, the second run, where --fail-only prints only the failures, ends with:

Test Summary: 2 out of 10 tests failed
Aggregated Failed Test Cases :
│────│───────────│──────│──────────────────────│────────│─────────────────────│
│ ID │ POLICY    │ RULE │ RESOURCE             │ RESULT │ REASON              │
│────│───────────│──────│──────────────────────│────────│─────────────────────│
│ 1  │ no-latest │      │ v1/Pod/shop/untagged │ Fail   │ Want fail, got pass │
│ 2  │ no-latest │      │ v1/Pod/shop/local    │ Fail   │ Want fail, got pass │
│────│───────────│──────│──────────────────────│────────│─────────────────────│
Error: 2 tests failed
1

The sed swaps only the validation, so the message is still built with image(); that does not matter, because the test compares verdicts and ignores messages.

Common mistakes

Where next