A múlt héten belefutottam abba, hogy a Kubernetes projekt csendben újraírta az image promotert, vagyis azt az eszközt, ami a hivatalos image-eket feltolja a registry.k8s.io alá. Nem is maga az átírás volt az érdekes, hanem az, hogy az új verzió már rendes SLSA provenance tanúsítványokat és cosign aláírásokat ad az image-ekhez minden tükörnél.

Ezen a ponton be kellett vallanom magamnak valamit: a saját image-eimet már jó ideje aláírom CI-ben, de a klaszter oldalon semmit nem kényszerítettem ki. Az aláírás ott volt, csak éppen senki nem nézte. Szóval végre rászántam magam, és rendbe tettem.

A kiindulás

A GitHub Actions pipeline-jaimban nagyjából egy éve ott van a cosign. Minden image kapott aláírást build közben:

cosign sign --yes \
  --oidc-issuer https://token.actions.githubusercontent.com \
  ghcr.io/myorg/myapp:${GITHUB_SHA}

Azért szerettem ezt a felállást, mert egyszerű volt. Kulcs nélküli aláírás a Sigstore Fulcio CA-val, vagyis nem kellett hosszú életű kulcsokat menedzselni, nem kellett titkokat forgatni, és nem kellett külön kulcskezelési procedúrát kitalálni. Az aláírás szépen ott élt a registry-ben az image mellett.

Csakhogy ez így inkább csak megnyugtató érzés volt, nem valódi védelem. A klasztereim ugyanúgy boldogan lehúztak volna aláíratlan image-eket is. Megcsináltam a látványos részt, csak az ellenőrzés maradt el.

Policy kikényszerítés Kyverno-val

Kyverno-t választottam, mert már amúgy is futott nálam más admission policy-khoz. Használhattam volna a Sigstore policy-controllerét is, de nem volt kedvem még egy webhookot berakni a láncba, ha nem muszáj.

Ez volt az első policy, amivel a cosign aláírásokat ellenőriztem:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-signatures
spec:
  validationFailureAction: Enforce
  background: false
  rules:
    - name: verify-ghcr-images
      match:
        any:
          - resources:
              kinds:
                - Pod
      verifyImages:
        - imageReferences:
            - "ghcr.io/myorg/*"
          attestors:
            - entries:
                - keyless:
                    subject: "https://github.com/myorg/*"
                    issuer: "https://token.actions.githubusercontent.com"
                    rekor:
                      url: https://rekor.sigstore.dev

Felraktam, harminc másodpercig elégedetten néztem magam elé, aztán végignéztem, ahogy a staging környezet egy jó része nem tud többé podot ütemezni.

Mi tört el azonnal

Init konténerek. Ez volt az első pofon. A pod nem csak a fő konténer image-ét húzza le, hanem az init konténerekét is, nálam pedig az egyik debug sidecar még aláíratlan volt. Minden pod, ami ettől függött, repült. A javítás egyszerű volt: ezt az image-et is alá kellett írni, vagy külön kivételt adni rá.

Helm chart image-ek. A következő gondot a külsős chartok okozták. Ha egy chart mondjuk Bitnami Redist használ, azt az image-et nem én kontrollálom, és utólag nem tudom aláírni. Ezeket külön kellett kezelni:

verifyImages:
  - imageReferences:
      - "ghcr.io/myorg/*"
    attestors:
      # ...
  - imageReferences:
      - "docker.io/bitnami/*"
      - "registry.k8s.io/*"
    mutateDigest: true
    verifyDigest: true
    required: false

Itt a required: false végül teljesen vállalható kompromisszumnak bizonyult. A Kyverno továbbra is digestre írja át a tageket, ami véd a tag mutation típusú húzások ellen, de nem vár el olyan aláírást, amit amúgy sem tudnék előállítani. Nem tökéletes, de a valóságban használható.

Cache-elt image-ek. Ez már idegesítőbb volt. Ha az image már ott volt a node-on, és a workload imagePullPolicy: IfNotPresent beállítást használt, akkor nem nagyon volt mit ellenőrizni, mert a kubelet nem húzta le újra. A Kyverno admissionnél vizsgál, nem futás közben. Azoknál a workloadoknál, ahol ez tényleg számított, átálltam imagePullPolicy: Always-ra.

SLSA provenance ellenőrzés hozzáadása

Az aláírás azt mondja meg, ki készítette az image-et. A provenance azt mondja meg, hogyan készült. Amikor láttam, hogy a Kubernetes projekt már SLSA tanúsítványokkal publikálja a saját image-eit, rögtön azt akartam, hogy nálam is ugyanez legyen.

GitHub Actions-ben hozzáadtam az SLSA generátort:

- uses: slsa-framework/slsa-github-generator/.github/workflows/[email protected]
  with:
    image: ghcr.io/myorg/myapp
    digest: ${{ steps.build.outputs.digest }}

Utána kibővítettem a Kyverno policy-t, hogy a provenance-et is ellenőrizze:

verifyImages:
  - imageReferences:
      - "ghcr.io/myorg/*"
    attestors:
      - entries:
          - keyless:
              subject: "https://github.com/myorg/*"
              issuer: "https://token.actions.githubusercontent.com"
    attestations:
      - type: https://slsa.dev/provenance/v1
        conditions:
          - all:
              - key: "{{ buildDefinition.buildType }}"
                operator: Equals
                value: "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1"

Ez már egy sokkal erősebb védőkorlát lett. Ha valaki a saját laptopjáról kézzel buildel egy image-et és feltolja a registry-be, az nem fog átmenni.

A cosign parancs, amit tényleg folyton használtam

Amikor valami nem működött, ezt a parancsot futtattam újra és újra:

cosign verify \
  --certificate-identity-regexp "https://github.com/myorg/.*" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/myorg/myapp@sha256:abc123...

Attesztációhoz pedig ezt:

cosign verify-attestation \
  --type slsaprovenance \
  --certificate-identity-regexp "https://github.com/slsa-framework/.*" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/myorg/myapp@sha256:abc123...

A kimenet JSON, ami technikailag hasznos, emberileg kevésbé. Ha átcsövezed jq .payload | base64 -d | jq .-on, már egész jól olvasható, és lehet belőle normálisan debugolni.

Teljesítmény hatás

A valós költség nálam nagyjából 200 ms extra admission késleltetés volt podonként. Ez nem nulla, de nem is akkora ár, hogy ne férjen bele. A Kyvernónak minden új image-nél beszélnie kell a registry-vel és a Rekor transparency loggal, szóval az első deploy tényleg lassabb lesz.

Utána viszont sokat segít a cache. Mivel a Kyverno eltárolja az ellenőrzés eredményét, ugyanannak a digestnek az újradeployolása már sokkal simább. Az első rollout lassabb, a következőek teljesen rendben vannak.

A gyenge pont a Rekor volt. Ha a Sigstore-nak rossz napja van, azt a deploy pipeline rögtön megérzi. Nálam maradt bekapcsolva a Rekor ellenőrzés, de a policy-t úgy hagytam, hogy később könnyen tudjak rajta finomhangolni:

verifyImages:
  - imageReferences:
      - "ghcr.io/myorg/*"
    attestors:
      - entries:
          - keyless:
              subject: "https://github.com/myorg/*"
              issuer: "https://token.actions.githubusercontent.com"
              rekor:
                url: https://rekor.sigstore.dev
                ignoreTlog: false
    useCache: true

Megérte?

Igen, egyértelműen.

Körülbelül egy héttel azután, hogy ezt bevezettem, az egyik CI job rosszul lett konfigurálva, és elkezdett aláíratlan image-eket pusholni egy új repository-ba. Ha nincs kikényszerítés, ezek valószínűleg szépen besétálnak a productionbe, mielőtt bárki észreveszi. Így viszont a deploy azonnal megállt, a hiba egyértelmű volt, és a fejlesztő gyorsan javította.

Az egész beállítás nagyjából egy nap tényleges munka volt, és ennek a nagy része a külsős image-ek körüli kivételek finomhangolásával ment el. Az alap aláírás-ellenőrzés gyorsan megvolt. Az időt inkább az vitte el, hogy úgy legyen elég szigorú, hogy közben ne törjön el minden chart, ami külső image-et húz.

A supply chain biztonság sokáig nekem is olyasminek tűnt, amit csak a nagyon paranoiás csapatok csinálnak. Ezt már nem gondolom így. Az xz backdoor, a polyfill.io balhé, meg a kompromittált npm csomagok végtelen sora után ez inkább alap higiénia, mint túlzás. Sokkal szívesebben szánok rá egy napot, mint hogy utána egy hétig magyarázzam, hogyan jutott be egy aláíratlan image a production klaszterbe.