Skip to content

About

Kubernetes validating admission webhook that rejects pods whose images carry no Sigstore signature from an accepted signer

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

sigstore-guard

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

Why TUF and not a pinned key

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.

Failure semantics

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.

failurePolicy vs failOpen

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.

The distinction failOpen actually turns on

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.

Identity matching is anchored, deliberately

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.

What gets checked

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): ...

Known gap: pods, not workloads

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.

Modes

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.

Layout

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.

Running it

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 assertions

The live tests reach the public Sigstore TUF mirror, Rekor, and real registries:

go test ./internal/verify/ -run TestLive -v

They 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.

Configuration

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

Limitations

  • Private registries. Verification uses the ambient keychain, so a pod's imagePullSecrets are 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.

License

Apache-2.0.

About

Kubernetes validating admission webhook that rejects pods whose images carry no Sigstore signature from an accepted signer

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages