Zero of 17 Pods Pin a Digest

A read-only Kyverno run against the real Pods in a namespace found 17 Pods and zero pinning an image by digest. That number is why the digest policy ships as three separate rules instead of one.

Zero of 17 Pods Pin a Digest

Before turning on a policy, it is worth asking what fraction of the cluster it would immediately reject. Kyverno's CLI can answer that without touching anything: kyverno apply --cluster -n <namespace> reads live Pods, creates nothing, changes nothing.

Run against a namespace of POC-deployed workloads — Harbor, SonarQube, Dependency-Track, a Trivy server — the answer was not ambiguous:

{
  "total": 17,
  "with_digest": 0,
  "without_digest": 17
}

Seventeen running Pods. Zero pinning an image by digest. The whole run finished in elapsed_ms=401 and exited 0, because it is a read.

All 17 Pods reference tags; none reference a digest
All 17 Pods reference tags; none reference a digest

A digest-requiring policy in Enforce mode would reject every workload in this namespace.

What that number actually means

It is not a verdict on the workloads. These are stock Helm charts deploying stock upstream images, and upstream charts reference tags, because tags are what chart values express. Nobody did anything wrong here; the ecosystem default is a tag.

What the zero does mean is that "require a digest" is not a policy you can enable. There is no long tail to chase, no handful of stragglers to fix — there is a complete rewrite of how images get referenced, and it starts in CI, not in the cluster. Enforce mode on day one would take the namespace down.

Three rules, deliberately separate

The policy file is built around that constraint. Rather than one require-image-digest rule, it carries three, and the file says why in a comment at the top: deliberately separate so they can be phased in independently.

  • require-image-tag — refuses an image with no tag at all. Untagged images silently resolve to :latest, so this rule catches the reference that looks pinned and is not.
  • disallow-latest-tag — refuses :latest outright, as mutable and unauditable.
  • require-image-digest — refuses anything without an @sha256: digest. This is the strict one.

The stated rollout order follows the cost to teams: turn on disallow-latest-tag first (cheap for teams), then require-image-digest once CI emits digests. Each rule can go to Enforce on its own schedule while the others stay in Audit. All three ship with failureAction: Audit and allowExistingViolations: true, so nothing already running is disturbed.

This is exactly the phase-in that a cosign verifyImages policy cannot have — signature verification refuses to combine mutateDigest: true with Audit, so it has to be rolled out by narrowing scope instead. A validate policy has the easier path, and this one takes it.

The rules overlap, which shows up in the counts

Against deliberately bad fixtures, the three rules produce more failures than failing Pods:

Applying 9 policy rule(s) to 4 resource(s)...
pass: 6, fail: 6, warn: 0, error: 0, skip: 0

A Pod with no tag at all trips both require-image-tag and require-image-digest. A :latest Pod trips both disallow-latest-tag and require-image-digest. The digest rule is a superset of the other two by construction — anything it accepts, they accept.

The good fixtures come back pass: 6, fail: 0 with exit 0. Note also that one of the failing fixtures fails at /spec/initContainers/0/image/: the rules match initContainers and ephemeralContainers as optional patterns alongside containers, because an init container pulls an image exactly like any other and is exactly where an unpinned reference hides.

Next: exit codes are the cheapest gate — how to make CI fail on the right thing without parsing a single report.