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:
1# je Architektur bauen, in die Registry kopieren ...
2for ARCH in amd64 arm64; do
3 docker buildx build --platform "linux/${ARCH}" ...
4 skopeo copy "docker-archive:...-${ARCH}.tar" \
5 "docker://${REGISTRY}/${img}:build-${ARCH}" --dest-creds "${CREDS}"
6done
7
8# ... zum Multi-Arch-Index zusammenfügen ...
9crane index append \
10 --manifest "${REGISTRY}/${img}:build-amd64" \
11 --manifest "${REGISTRY}/${img}:build-arm64" \
12 --tag "${REGISTRY}/${img}:${TAG}"
13
14# ... und über den Digest signieren + sofort gegenprüfen
15digest="$(skopeo inspect --creds "${CREDS}" "docker://${REGISTRY}/${img}:${TAG}" --format '{{.Digest}}')"
16cosign sign --yes --recursive --key "${COSIGN_KEY}" "${REGISTRY}/${img}@${digest}"
17cosign 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:
1apiVersion: kyverno.io/v1
2kind: ClusterPolicy
3metadata:
4 name: verify-own-image-signatures
5spec:
6 validationFailureAction: Enforce
7 failurePolicy: Fail
8 webhookTimeoutSeconds: 30
9 rules:
10 - name: verify-cosign-signature
11 match:
12 any:
13 - resources: { kinds: [Pod] }
14 verifyImages:
15 - imageReferences:
16 - "oci.${EXTERNAL_DOMAIN}/*"
17 imageRegistryCredentials:
18 secrets: [reg-pull-secret]
19 attestors:
20 - count: 1
21 entries:
22 - keys:
23 publicKeys: |-
24 -----BEGIN PUBLIC KEY-----
25 MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcD...
26 -----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:
Nicht nur die eigenen Images#
Die gleiche Idee wende ich auf die Werkzeuge an, denen ich am meisten vertrauen muss — allerdings mit einem anderen Trust-Modell. Meine eigenen Images prüfe ich gegen einen statischen PEM-Public-Key. Flux dagegen signiert keyless: Es gibt keinen festen Schlüssel, gegen den man verifiziert, sondern eine Identität. Die Policy verify-flux-image-signatures akzeptiert nur Flux-Images, die nachweislich aus der GitHub-Actions-Pipeline von fluxcd stammen — gegengezeichnet von Sigstores Fulcio und in Rekors Transparenz-Log protokolliert:
1attestors:
2 - entries:
3 - keyless:
4 subject: "https://github.com/fluxcd/*"
5 issuer: "https://token.actions.githubusercontent.com"
6 rekor:
7 url: https://rekor.sigstore.dev
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. Statischen Key und keyless-OIDC mischt man dabei besser nicht in einer Regel: zwei Policies, zwei Trust-Modelle, klar getrennte imageReferences — sonst wird das Debuggen zur Qual.
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.Wenn ein Pod hängt — und beim Key-Wechsel#
Bleibt ein Pod im ReplicaSet stecken, zeigt kubectl describe fast immer Kyverno als Ursache, und ein PolicyReport benennt die blockierende Regel. Der Fix ist meist trivial: das gemeinte Image in der CI signieren. Heikler ist die Key-Rotation, denn ein neues Schlüsselpaar muss an drei Stellen gleichzeitig ankommen — ins CI-Secret (privater Key), in die ClusterPolicy (öffentlicher PEM) und auf jeden bereits deployten Digest, der neu signiert werden will. Wer den Key tauscht, ohne die laufenden Digests nachzusignieren, schaltet sich die betroffenen Pods beim nächsten Neustart selbst ab. Ein kurzes Wartungsfenster oder paralleles Key-Rolling gehört hier eingeplant.
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
.
