A Kubernetes validating admission webhook, in Go, that rejects pods whose container images do not carry a Sigstore signature from an accepted signer. The trust root comes from TUF, so signing keys can rotate without redeploying the webhook.
kubectl run app --image=docker.io/library/busybox:1.36
Error from server (Forbidden): admission webhook "images.sigstore-guard.io" denied the request:
sigstore-guard rejected 1 image(s): docker.io/library/busybox:1.36 (container app):
no signature is attached to this image
Most "verify your images" walkthroughs end with a Fulcio public key pasted into a flag. That works until the key rotates, at which point every deployment that pinned it starts rejecting valid signatures, and the fix is a redeploy of every cluster.
TUF (The Update Framework) solves the
distribution problem instead. What ships in the pod is a small bootstrap
root.json. From there the client walks a signed metadata chain to the current
key set, with rollback protection and threshold signing along the way. Key
rotation becomes a metadata update that clients pick up on their own. This
webhook refreshes the chain every six hours by default, well inside the daily
expiry of Sigstore's timestamp role.
internal/trust wraps sigstore-go's LiveTrustedRoot for this. Air-gapped
clusters can bypass TUF with --trusted-root=/path/to/trusted_root.json and
take on rotation themselves.
This is the part that decides whether the webhook protects the cluster or takes it down, and it involves three separate controls that are easy to confuse.
| Control | Owner | Fires when |
|---|---|---|
failurePolicy (in ValidatingWebhookConfiguration) |
API server | It cannot reach the webhook, or the webhook does not answer within timeoutSeconds |
failOpen (in the policy ConfigMap) |
This webhook | It is running fine but cannot reach the registry or TUF |
failurePolicy: Fail is set, because a security control that can be disabled by
crashing it is not a control. That choice is what makes the rest of the
deployment non-negotiable: two replicas, spread across nodes, and two
independent namespace exclusions so the webhook can never gate its own recovery.
timeoutSeconds: 15 deliberately exceeds the webhook's own --review-timeout
of 10s. If they were reversed, the API server would give up first and
failurePolicy would silently decide every outcome, making failOpen dead
config.
Two failures look alike and must not be treated alike:
- The image was checked and is not acceptably signed. A definite answer.
- The image could not be checked. The registry timed out, credentials were rejected, TUF metadata expired. No answer at all.
Collapsing these is how signature enforcement causes an outage: a registry blip reads as "unsigned" and every pod in the cluster is refused.
internal/verify keeps them apart by returning *IncompleteError only for the
second kind, mapped from cosign's own error taxonomy (its VerificationFailure
type is documented as covering verification errors "vs. transient,
misconfiguration, transport, or authentication related issues").
failOpen applies only to the second kind. An image successfully determined
to be unsigned is always rejected, whatever failOpen says. Otherwise
failOpen would quietly mean "disabled", and
TestFailOpenDoesNotExcuseAnUnsignedImage exists to keep it that way.
Which to choose is a real trade-off with no free answer:
failOpen: false(default). A registry outage stops new pods from scheduling. Autoscaling stalls, rescheduling stalls. The cluster is safe and stuck.failOpen: true. A registry outage silently disables enforcement, which is also exactly what an attacker who can disrupt your registry would want. Every admitted-but-unverified pod is surfaced as a warning, so at least it is visible.
Listing signers matters more than most examples suggest. With no identities
configured, any signature that chains to the public Fulcio root is accepted,
including one produced by an attacker's own GitHub Actions workflow. The
webhook logs a warning at startup when it is running in enforce with an empty
identity list.
There is a sharper edge here. cosign matches SubjectRegExp and IssuerRegExp
with an unanchored regexp.MatchString (pkg/cosign/verify.go). A policy of:
identities:
- subjectPattern: "keyless@myorg.iam.gserviceaccount.com"
issuerPattern: "https://accounts.google.com"would therefore also accept a certificate whose subject is
keyless@myorg.iam.gserviceaccount.com.attacker.test.
policy.Compile anchors every pattern at both ends before it reaches cosign,
and skips the wrapping when the operator already anchored it themselves.
TestIdentityPatternsAreAnchored covers the unit case and
TestLiveAnchoredIdentityStillMatchesTheRealSigner confirms the anchored form
still matches a real signer against live Sigstore infrastructure.
Every image in the pod: containers, initContainers, and
ephemeralContainers. Ephemeral containers are included because kubectl debug
injects one into a running pod, and an unverified debug image is a
straightforward way to get arbitrary code next to a workload's volumes and
service account token.
Images repeated across containers are verified once. Rejections name the container so there is something to edit:
sigstore-guard rejected 1 image(s): ghcr.io/org/bad:v1 (initContainer setup, container main): ...
The webhook gates Pods. A Deployment referencing an unsigned image is accepted
by the API server, and the rejection surfaces later as the ReplicaSet fails to
create pods. The failure is contained but the feedback is indirect, showing up
in ReplicaSet events, some distance from the kubectl apply that caused them.
Gating Deployments, StatefulSets, DaemonSets, CronJobs and the rest directly would improve the message at the cost of a template-extraction path per workload kind. Pods are the chokepoint everything funnels through, so it is correct, just less friendly.
| Mode | Behaviour |
|---|---|
enforce |
Rejects pods that fail verification |
warn |
Admits and returns an admission warning, which kubectl prints |
disabled |
Admits without verifying, and never calls the verifier |
Start at warn on a live cluster. It is the only safe way to find out how many
of your images are actually unsigned.
A note on the mechanism: a validating webhook cannot annotate objects, so
warn uses AdmissionResponse.Warnings, so the message reaches the client directly.
Annotating would require a mutating webhook, which is a heavier thing to put in
the request path for what amounts to a dry run.
cmd/sigstore-guard/ TLS server, flags, wiring
internal/policy/ Enforcement modes, exemptions, identity compilation. No I/O.
internal/trust/ TUF-backed trusted root with periodic refresh
internal/verify/ cosign verification; the incomplete/unsigned split
internal/admission/ AdmissionReview decode, image extraction, decisions
deploy/ Namespace, RBAC, Deployment, Service, ConfigMap, webhook config
hack/gen-certs.sh Self-signed CA and caBundle injection
hack/e2e.sh End-to-end assertions against a live cluster
internal/policy and internal/admission perform no I/O and take the verifier
as an interface, so every decision path is tested against verify.Fake without
a cluster or a network.
Requires Go 1.26+, Docker, kind, and kubectl.
make check # vet + unit tests
make kind-up # create the cluster
make deploy # build, load, apply manifests, issue certs
make e2e # end-to-end assertionsThe live tests reach the public Sigstore TUF mirror, Rekor, and real registries:
go test ./internal/verify/ -run TestLive -vThey are skipped under -short. They exist because this component's whole job
is agreeing with the real Sigstore ecosystem, and a fake-only suite would pass
just as happily with the trust root wired up backwards.
Full policy reference is in deploy/policy.yaml. Unknown fields are rejected at
load time: a misspelled exemptNamespace would otherwise parse cleanly and
exempt nothing, which is the worst failure mode for a security policy because it
looks applied.
Flags of note:
| Flag | Default | Purpose |
|---|---|---|
--policy |
/etc/sigstore-guard/policy/policy.yaml |
Policy document |
--trusted-root |
(unset) | Bypass TUF with a local trusted_root.json |
--tuf-mirror |
Sigstore production | Alternate TUF repository |
--tuf-cache |
/var/cache/sigstore-guard/tuf |
Must be writable |
--tuf-refresh-interval |
6h |
Trusted root refresh period |
--review-timeout |
10s |
Must stay below the webhook's timeoutSeconds |
- Private registries. Verification uses the ambient keychain, so a pod's
imagePullSecretsare not consulted. Private images need credentials mounted into the webhook itself. - No verification cache. Every admission re-verifies, which costs a registry round trip and a Rekor lookup per distinct image. Fine at small scale; a digest-keyed cache with a short TTL is the obvious next step.
- No attestation policy. Signature presence and signer identity are checked. Verifying in-toto attestations such as SLSA provenance is a natural extension and is not implemented.
- The TUF cache is an
emptyDir. It survives container restarts but not pod rescheduling, so a rescheduled pod refetches the metadata chain. A PVC would fix it at the cost of a stateful dependency.
Apache-2.0.