🔐 Kyverno's Mutating Webhook

10/30/2024

The Problem

I recently discovered Kyverno's mutating admission webhook and came up with a usage that I think is pretty neat.

You're running a Kubernetes cluster in a corporate environment. Security requires that all container images come from your private registry — no pulling directly from Docker Hub or other public registries in production. Fair enough. Now enforce that across dozens of teams, hundreds of deployments, and developers who keep writing image: nginx:latest in their manifests.

You could reject non-compliant images and make everyone fix their YAML. Or you could quietly rewrite them at admission time — which is exactly what Kyverno's mutating webhook lets you do.


The Idea

Kyverno can intercept Pod creation and mutate the spec before it hits the cluster. In this case: replace public image registries with your private mirror, transparently. Developers write nginx:1.25, the cluster pulls from docker-remote.imageregistry.yourorg.com/library/nginx:1.25. Nobody has to change their workflow.

The mapping between public and private registries can be defined directly in the policy — or externalized into a ConfigMap if you need flexibility across clusters (more on that below).


The Policy

Here's the ClusterPolicy that does the heavy lifting:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: mutate-public-registry-images
  annotations:
    pod-policies.kyverno.io/autogen-controllers: none
    policies.kyverno.io/category: cluster-base
    policies.kyverno.io/title: Map to private remote mirror
    policies.kyverno.io/description: ...
spec:
  admission: true
  background: false
  rules:
    - name: replace-image-registry
      match:
        resources:
          kinds:
            - Pod
          operations:
            - CREATE
      context:
        - name: mappings
          variable:
            value:
              docker.io: docker-remote.imageregistry.yourorg.com
      preconditions:
        all:
          - key: "{{ request.object.spec.imagePullSecrets[0].name || '' | length(@) }}"
            operator: Equals
            value: 0
      mutate:
        foreach:
          # Handle images referenced by tag
          - list: request.object.spec.containers
            patchesJson6902: |-
              - path: /spec/containers/{{elementIndex}}/image
                op: replace
                value: '{{mappings.value."{{images.containers."{{element.name}}".registry}}"}}/{{ ( images.containers."{{element.name}}".registry == 'docker.io' && regex_match('^[a-z0-9-]+(:.*)?$','{{images.containers."{{element.name}}".path}}') && 'library/') || '' }}{{ images.containers."{{element.name}}".path}}:{{images.containers."{{element.name}}".tag}}'
            preconditions:
              all:
                - key: '{{mappings.value."{{images.containers."{{element.name}}".registry}}" || ''''}}'
                  operator: NotEquals
                  value: ""
                - key: '{{ images.containers."{{element.name}}".digest || ''''}}'
                  operator: Equals
                  value: ""
          # Handle images referenced by digest
          - list: request.object.spec.containers
            patchesJson6902: |-
              - path: /spec/containers/{{elementIndex}}/image
                op: replace
                value: '{{mappings.value."{{images.containers."{{element.name}}".registry}}"}}/{{ ( images.containers."{{element.name}}".registry == 'docker.io' && regex_match('^[a-z0-9-]+(:.*)?$','{{images.containers."{{element.name}}".path}}') && 'library/') || '' }}{{ images.containers."{{element.name}}".path}}@{{ images.containers."{{element.name}}".digest}}'
            preconditions:
              all:
                - key: '{{mappings.value."{{images.containers."{{element.name}}".registry}}" || ''''}}'
                  operator: NotEquals
                  value: ""
                - key: '{{ images.containers."{{element.name}}".digest || ''''}}'
                  operator: NotEquals
                  value: ""

It's not pretty — Kyverno's JMESPath expressions for image parsing are verbose. But it works, and once it's in place you don't touch it again.

What's Happening Here

The policy matches on Pod CREATE events and iterates over all containers. For each container, it:

  1. Looks up the image's registry in the inline mapping
  2. If a mapping exists, rewrites the image reference to point to the private mirror
  3. Handles a Docker Hub quirk: official images like nginx need library/ prepended to the path
  4. Handles both tag-based (nginx:1.25) and digest-based (nginx@sha256:...) references separately

The autogen-controllers: none annotation is important — it disables Kyverno's automatic rule generation for Deployments, StatefulSets, etc. Since all of those ultimately create Pods, matching on Pod directly covers everything, including resources Kyverno's autogen doesn't know about.

The precondition checking imagePullSecrets ensures the policy only kicks in for pods that don't already have explicit pull credentials configured. If a pod already has imagePullSecrets set, it's intentionally pulling from a specific private registry — rewriting the image reference would likely break it.


Why This Is Useful

Validation policies ("reject if image isn't from our registry") are the obvious approach, but they create friction. Developers have to know about your private registry, update their manifests, and deal with failed deployments when they forget. Mutation removes that friction entirely.

It's especially handy for:

  • Third-party Helm charts that hardcode public registries — you don't need to override every single image value
  • Quick prototyping — developers can use standard images without thinking about registry policies
  • Consistency — every image in the cluster goes through your mirror, no exceptions

A Few Things to Watch Out For

The JMESPath expressions are fragile-looking but stable once tested. I'd recommend testing thoroughly with kyverno apply against sample Pod specs before rolling this out.

You can also test your policies in the Kyverno Playground — honestly, it's the only practical way to get these JMESPath expressions right.

Also, if you're using Argo CD alongside Kyverno, there are some gotchas around resource syncing and mutation. Nirmata has a good writeup on that: 3 Essential Tips for Using Argo CD and Kyverno.


Conclusion

Kyverno's mutating webhook is one of those features that sounds niche until you need it — and then it's exactly the right tool. Transparent image registry rewriting is a clean solution to a messy problem. The example above hardcodes the mapping for simplicity, but in practice you'd probably externalize it into a ConfigMap so you can add registries without touching the policy:

context:
  - name: mappings
    configMap:
      name: public-registry-mapping
      namespace: kube-system

If you're running a private registry mirror and tired of chasing developers to fix their image references, give this a try.


Further Reading