Signierte Images erzwingen: Cosign in der Pipeline, Kyverno im Cluster

Wie selbstgebaute OCI-Images in der CI mit cosign signiert und im Cluster von Kyverno-Policies im Enforce-Modus verifiziert werden
Table of contents

Wer eigene Container-Images baut und in einer eigenen Registry hält, hat ein Vertrauensproblem in petto: Woher weiß der Cluster, dass ein Image wirklich aus meiner Pipeline stammt und nicht unterwegs manipuliert wurde? Meine Antwort ist eine geschlossene Kette aus zwei Hälften — cosign signiert in der CI, Kyverno verifiziert im Cluster und lässt nichts Unsigniertes durch.

Das Bedrohungsmodell

Meine selbstgebauten Images liegen in einer eigenen Zot -Registry unter oci.this-is-fine.io. Ohne weitere Maßnahmen würde der Cluster jedes Image ziehen, das unter diesem Namen liegt — egal, wer es dort abgelegt hat. Ein kompromittierter Registry-Zugang oder ein versehentlich falsch getaggtes Image genügt, und schon läuft fremder Code. Was fehlt, ist ein kryptografischer Nachweis von Herkunft und Integrität. Genau das liefert eine Signatur über den Image-Digest.

Hälfte eins: Signieren in der Pipeline

Gebaut wird auf einem sourcehut -Build-Runner. Der relevante Schritt baut jedes Image für beide Architekturen, fügt die Einzel-Manifeste zu einem Multi-Arch-Index zusammen und signiert anschließend — rekursiv, sodass der Index und seine Kind-Manifeste abgedeckt sind:

# je Architektur bauen, in die Registry kopieren ...
for ARCH in amd64 arm64; do
  docker buildx build --platform "linux/${ARCH}" ...
  skopeo copy "docker-archive:...-${ARCH}.tar" \
    "docker://${REGISTRY}/${img}:build-${ARCH}" --dest-creds "${CREDS}"
done

# ... zum Multi-Arch-Index zusammenfügen ...
crane index append \
  --manifest "${REGISTRY}/${img}:build-amd64" \
  --manifest "${REGISTRY}/${img}:build-arm64" \
  --tag "${REGISTRY}/${img}:${TAG}"

# ... und über den Digest signieren + sofort gegenprüfen
digest="$(skopeo inspect --creds "${CREDS}" "docker://${REGISTRY}/${img}:${TAG}" --format '{{.Digest}}')"
cosign sign   --yes --recursive --key "${COSIGN_KEY}"     "${REGISTRY}/${img}@${digest}"
cosign verify              --key "${COSIGN_PUB_KEY}" "${REGISTRY}/${img}@${digest}"

Wichtig: Signiert wird über @${digest}, nicht über den Tag. Tags sind beweglich, Digests sind es nicht — die Signatur klebt am exakten Inhalt. Der private cosign-Key und die Registry-Zugangsdaten kommen aus dem Secret-Store des Build-Runners und tauchen nie im Repository auf.

Hälfte zwei: Erzwingen im Cluster

Die Signatur allein nützt nichts, solange niemand sie prüft. Diese Rolle übernimmt Kyverno als Admission-Webhook. Eine ClusterPolicy im Enforce-Modus verifiziert jeden Pod, dessen Image aus meiner Registry stammt, gegen den passenden öffentlichen cosign-Schlüssel:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-own-image-signatures
spec:
  validationFailureAction: Enforce
  failurePolicy: Fail
  webhookTimeoutSeconds: 30
  rules:
    - name: verify-cosign-signature
      match:
        any:
          - resources: { kinds: [Pod] }
      verifyImages:
        - imageReferences:
            - "oci.${EXTERNAL_DOMAIN}/*"
          imageRegistryCredentials:
            secrets: [reg-pull-secret]
          attestors:
            - count: 1
              entries:
                - keys:
                    publicKeys: |-
                      -----BEGIN PUBLIC KEY-----
                      MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcD...
                      -----END PUBLIC KEY-----

Versucht jemand, einen Pod mit einem unsignierten oder nachträglich veränderten Image aus oci.this-is-fine.io zu starten, schlägt die Verifikation fehl und die Admission wird abgelehnt — der Pod entsteht gar nicht erst. Die beiden Hälften greifen so ineinander:

flowchart LR subgraph CI["sourcehut CI"] B["buildx (amd64/arm64)"] --> IDX["crane: Multi-Arch-Index"] IDX --> SIGN["cosign sign --recursive<br/>(privater Key, über Digest)"] end SIGN -->|"push + Signatur"| ZOT["Zot · oci.this-is-fine.io"] subgraph K8S["hydra"] API["kube-apiserver"] --> KV["Kyverno Admission-Webhook"] KV -->|"verifyImages (öffentl. Key)"| DEC{"Signatur gültig?"} DEC -->|ja| RUN["Pod läuft"] DEC -->|nein| DENY["Admission abgelehnt"] end ZOT --> API

Nicht nur die eigenen Images

Die gleiche Idee wende ich auf die Werkzeuge an, denen ich am meisten vertrauen muss: eine zweite Policy (verify-flux-image-signatures) verifiziert die Images der Flux-Controller gegen den von Flux veröffentlichten Schlüssel. Selbst die GitOps-Maschine, die alles andere ausrollt, muss sich also kryptografisch ausweisen — und eine dritte Policy stellt sicher, dass Flux nur aus erwarteten Git-Quellen liest.

failurePolicy: Fail ist scharf. Ist der Kyverno-Webhook nicht erreichbar, werden betroffene Pods abgelehnt — im Zweifel cluster-weit. Genau deshalb ist die Policy eng auf oci.${EXTERNAL_DOMAIN}/* und die Flux-Images gerahmt und mit einem webhookTimeoutSeconds-Limit versehen. Wer den Enforce-Modus blind auf alle Images loslässt, legt sich beim ersten Webhook-Ausfall selbst lahm.

Fazit

Signieren ohne Verifizieren ist Theater, Verifizieren ohne Signieren ist unmöglich — erst die geschlossene Kette aus CI-seitigem cosign sign und cluster-seitigem Kyverno-Enforce ergibt einen echten Gewinn an Vertrauen. Vom Build bis zum laufenden Pod gilt: kein gültiger Schlüssel, kein Container. Dieselbe Registry und derselbe Mechanismus tragen später auch die Images meines AI-Operators.