Verify at admission

policy-controller/Kyverno; identity + attestations by digest.

Expert35 min · lesson 13 of 15

Your pipeline does everything right. Every release image is signed with cosign keyless, carries SLSA (Supply-chain Levels for Software Artifacts) v1.0 provenance, ships an SBOM (Software Bill of Materials, the list of what went into the build), and has a Rekor inclusion proof showing the signature was written into a public tamper-evident log. Then at 03:00 an on-call engineer, rolling back a bad deploy, points a Deployment at image: registry.acme.internal/api:hotfix. That tag was quietly overwritten last week by an attacker holding a leaked push token. Kubernetes schedules it without hesitation. Not one piece of the evidence you spent this whole course producing gets consulted, because nothing in the cluster is obligated to look. A nightclub can print flawless ID cards in the back office and still let anyone walk in if nobody works the door. Admission verification is the person on the door. An admission controller is a plugin that sits inside the Kubernetes API server's request path, inspects every object before it is saved, and can refuse the ones whose images fail policy. It is the one place that can say 'no' at the exact moment a workload is about to run.

Where enforcement actually happens

When you kubectl apply a Pod, or a Deployment, Job, or StatefulSet that will spawn Pods, the request walks through the API server in a fixed order: authentication, then authorization (RBAC, role-based access control), then mutating admission, then schema validation, then validating admission, and only then a write to etcd, the cluster's database. RBAC decides who may create the object. It says nothing at all about whether the image inside is trustworthy. That check lives in the validating-admission stage. A ValidatingWebhookConfiguration registers an external webhook, a small HTTP service the API server phones mid-request, and hands it an AdmissionReview payload describing the object. The webhook answers allowed: true or allowed: false, with a message. Sigstore's policy-controller and Kyverno both install as exactly that kind of webhook. policy-controller also runs a mutating webhook that rewrites each image tag into its sha256: digest, so the bytes it verified are provably the bytes the kubelet later pulls. The tag cannot be repointed in the gap between verification and pull.

One subtlety decides whether any of this holds up: match on the object that actually carries the image, the Pod, not only the Deployment. A Deployment's template produces Pods, and so do bare Pods, Jobs, CronJobs, and DaemonSets. Each of those is its own separate road to a running container. policy-controller and Kyverno both resolve down to the Pod spec, so every road is covered. Kyverno states the same intent with a verifyImages rule and validationFailureAction: Enforce. policy-controller is Sigstore-native and configured entirely through ClusterImagePolicy objects. Either tool is a correct choice. What decides the outcome is that the rule is set to enforce rather than audit.

What the gate checks in a keyless signature

A cosign keyless signature is not a loose file sitting on someone's laptop. It travels like a wax seal packed in the same crate as the goods: it is an OCI artifact (Open Container Initiative, the format registries speak) stored in the same registry as the image, under a tag derived from the image digest, sha256-<digest>.sig. What that artifact carries is a simple-signing payload, a small JSON document (media type application/vnd.dev.cosign.simplesigning.v1+json) whose critical section pins the image's docker-reference and its sha256: manifest digest and sets type to 'cosign container image signature'. The raw signature over those exact bytes rides in the layer's dev.cosignproject.cosign/signature annotation. The SLSA provenance you produced earlier in this course travels differently. An attestation is a DSSE envelope (Dead Simple Signing Envelope): a payloadType field plus a base64 in-toto Statement, with the signature computed over the PAE (pre-authentication encoding) of that payload. PAE folds the payload type into the signed bytes, so nobody can hand you a document of one type and pass it off as another. Both are signed by the same sort of identity: an ephemeral Fulcio X.509 certificate, good for roughly ten minutes, whose SAN (Subject Alternative Name, the identity field inside a certificate) holds the signer's OIDC (OpenID Connect) identity, which for a GitHub build is the workflow ref. The certificate also carries a custom extension, OID 1.3.6.1.4.1.57264.1.1, recording which OIDC issuer vouched for that identity. At admission the controller fetches the signature and the certificate, checks the certificate chains up to the Fulcio root in its trust store, compares the SAN against your policy's subjectRegExp and the issuer extension against your issuer, validates the embedded SCT (Signed Certificate Timestamp) proving the certificate was entered in the CT log (certificate transparency, a public append-only record of issued certificates), and confirms the Rekor inclusion proof, which shows the signature was logged while that short-lived certificate was still valid. The image is admitted only when every one of those holds.

The admission decision for one image
Pod admission request
image resolved tag → digest, signature fetched from registry
no signature in registry
REJECT
'no matching signatures'
signed, SAN ≠ subjectRegExp or wrong issuer
REJECT
a valid Fulcio identity, but not yours
identity OK, required attestation missing
REJECT
no SLSA v1.0 provenance predicate
identity + SCT + Rekor + attestations verified
ADMIT
run exactly these digest-bound bytes
RBAC controls who may create the Pod. Only the admission gate controls whether its image is trustworthy, and a signature on its own is not enough: the identity has to be pinned and the required attestations present.

A ClusterImagePolicy that pins identity

The policy below demands two things of every image from your registry. It must carry a keyless signature from one exact identity, and it must carry a SLSA v1.0 provenance attestation. subjectRegExp is anchored to a single repository and workflow ref. That anchoring is identity pinning, and it is what separates 'signed' from 'signed by us'. A signature from a perfectly genuine Fulcio identity that is not yours, say an attacker's own public GitHub Actions run, is refused, because the certificate SAN will not match the regex.

clusterimagepolicy.yaml — require a pinned identity + SLSA provenance
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
name: require-release-identity
spec:
images:
- glob: "registry.acme.internal/**"
authorities:
- name: keyless-github
keyless:
url: https://fulcio.sigstore.dev
identities:
- issuer: https://token.actions.githubusercontent.com
# anchored regex: ONLY this repo + workflow + branch may sign
subjectRegExp: "^https://github\\.com/acme/api/\\.github/workflows/release\\.yml@refs/heads/main$"
ctlog:
url: https://rekor.sigstore.dev # inclusion proof must verify here
attestations:
- name: require-slsa-provenance
predicateType: "https://slsa.dev/provenance/v1" # SLSA v1.0 (buildDefinition/runDetails)
policy:
type: cue
data: |
// asserted over the in-toto Statement's predicate
predicate: runDetails: builder: id: =~"^https://github.com/acme/"

images.glob scopes the policy to your registry. The keyless authority names the Fulcio instance and the identities you are willing to accept. ctlog.url points at the Rekor instance whose inclusion proof has to check out. (The field is named ctlog but refers to Rekor, a naming quirk in policy-controller that catches everyone the first time.) The attestations entry demands a DSSE-wrapped SLSA v1.0 provenance predicate and runs a CUE policy over it. CUE is a small configuration language used here to assert facts about the JSON, and the assertion is that the builder id is your trusted builder, so an image signed by the right identity but built somewhere unexpected still fails. One more detail matters: policy-controller only acts on namespaces you opt in by label. That keeps system namespaces from deadlocking on a webhook that itself needs those namespaces in order to start.

unsigned image is refused at the cluster door
# Opt the namespace in to enforcement
$ kubectl label namespace apps policy.sigstore.dev/include=true
namespace/apps labeled
# The attacker's overwritten :hotfix tag was never signed by the release workflow
$ kubectl run rogue --image=registry.acme.internal/api:hotfix -n apps
Error from server (BadRequest): admission webhook "policy.sigstore.dev" denied the request:
validation failed: failed policy: require-release-identity: spec.containers[0].image
registry.acme.internal/api@sha256:71d3f0c9a1e2b4d5c6a7f8091a2b3c4d5e6f70819a2b3c4d5e6f7081920a3b4c5
no matching signatures:

The rogue image is refused before a Pod object is ever written to etcd. The webhook returns allowed: false and kubectl prints the denial back at you. Because the webhook's failurePolicy is Fail, an image that cannot be verified counts as a failure rather than being waved through. Now the real, signed release:

the signed release is admitted — and pinned to the verified digest
$ kubectl run api --image=registry.acme.internal/api:1.4.2 -n apps
pod/api created
# policy-controller's mutating webhook rewrote the tag to the digest it actually verified
$ kubectl get pod api -n apps -o jsonpath='{.spec.containers[0].image}{"\n"}'
registry.acme.internal/api@sha256:9f2c8e1b7a04d6f3c2b1a09e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8

The kubelet will pull that digest and nothing else, the same bytes the gate verified. To see precisely what the gate matched against, run the same keyless verification by hand and read the certificate identity straight out of the signature:

what the gate saw: the Fulcio cert identity + issuer
$ cosign verify \
--certificate-identity-regexp '^https://github\.com/acme/api/\.github/workflows/release\.yml@' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
registry.acme.internal/api@sha256:9f2c... 2>&1 | head -6
Verification for registry.acme.internal/api@sha256:9f2c... --
The following checks were performed on each of these signatures:
- The cosign claims were validated
- Existence of the claims in the transparency log was verified offline
- The code-signing certificate was verified using trusted certificate authority certificates
# The SAN + issuer extension the ClusterImagePolicy pinned against:
$ cosign verify ... --output json | jq -r '.[0].optional.Subject, .[0].optional.Issuer'
https://github.com/acme/api/.github/workflows/release.yml@refs/heads/main
https://token.actions.githubusercontent.com

Cost, trust roots, and running it in production

Two operational worries dominate once this is switched on. First, verification is not free. Every distinct image digest triggers registry round-trips to fetch the signature and the attestations, plus Rekor and CT lookups. policy-controller caches results per digest and can verify against an offline Rekor bundle, so pin by digest and let the cache earn its keep instead of re-resolving mutable tags on every Pod. Second, the trust anchors, meaning the Fulcio root, the Rekor public key, and the CT log key, reach the controller through a TUF (The Update Framework) root that it refreshes on a schedule. TUF is what lets those keys rotate without you hard-coding them anywhere, and what stops an attacker from feeding you an old set of keys or a mix of fresh and stale ones. If you run private Sigstore infrastructure you must supply your own TrustRoot custom resource, or verification will fail against the public roots. Pin identities narrowly, keep the TUF root current, and review edits to the ClusterImagePolicy the way you review production code, because this gate is only ever as trustworthy as the registry it pulls those signatures from, which is the next thing to harden.

A webhook that fails open admits everything it cannot check
Admission enforcement only works while the webhook is reachable. If its failurePolicy is Ignore, or the policy-controller / Kyverno Pods are down, or a namespace was never labelled for inclusion, the API server carries on as though the check passed, and unsigned images sail straight through. Set failurePolicy: Fail, run the controller with multiple replicas and a PodDisruptionBudget, and monitor its availability as a hard production dependency. A verification gate that is down is a verification gate that is off. Exempt only the minimum system namespaces needed to bootstrap the controller itself.
Quick check
01Your ClusterImagePolicy pins issuer: https://token.actions.githubusercontent.com but leaves subjectRegExp as ".*". An attacker who can push to your registry builds a malicious image, signs it with cosign keyless from their own public GitHub Actions workflow, and pushes it. What does the gate do?
Incorrect — Nothing in this policy describes 'your pipeline'. With subjectRegExp set to ".*", any Subject Alternative Name passes, so the gate has no way to tell the attacker's identity from yours.
Correct — GitHub's public OIDC issuer is shared by every GitHub Actions run anywhere, and ".*" happily matches the attacker's workflow SAN, so identity pinning is switched off in practice. Anchor subjectRegExp to your exact repo, workflow, and ref.
Incorrect — No forgery is needed. The attacker's signature is genuinely logged in Rekor under their real identity, and this over-broad policy accepts exactly that.
Incorrect — Keyless signing uses no long-lived key and no hardware token. Fulcio issues a short-lived certificate bound to an OIDC identity, so the control is the identity, never a key.
02Alongside answering allowed: true or false, Sigstore's policy-controller runs a mutating webhook that rewrites each image's tag into its sha256: digest before the object is stored. Why does that rewrite matter for security?
Incorrect — No. The rewrite is a security control, not a performance trick, and the kubelet still pulls from the registry.
Correct — Pinning to the verified digest closes the window in which a mutable tag could be moved to different content after the check but before the pull.
Incorrect — No. Pull credentials have nothing to do with resolving a tag into a digest at admission time.
Incorrect — No. The webhook edits the reference inside the Kubernetes object and changes nothing at all in the registry.
03Your verifyImages policy is scoped to match objects of kind Deployment only, with enforcement turned on. A developer creates a CronJob whose pod template points at an unsigned image. What happens at admission?
Correct — Bare Pods, Jobs, CronJobs, and DaemonSets each produce Pods on their own, so a policy has to match the Pod spec, not only Deployments, to cover every path.
Incorrect — No. A policy acts only on the objects it is scoped to match, and scoping to Deployment leaves the other Pod-producing kinds uncovered.
Incorrect — No. There is no built-in risk ranking by kind. Coverage depends entirely on whether the rule matches the object carrying the image.
Incorrect — No. Admission is a binary allow or deny at write time. There is no quarantine state, and an object the rule never matched is admitted.

Try this

Work through “Cost, trust roots, and running it in production” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: a webhook that fails open admits everything it cannot check. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related