Security-as-Code

Pipeline bez sekretów: SPIFFE jako tożsamość zamiast hasła w CI

Kolejny wpis z serii use-case’ow: konkretne wdrozenie zamiast teorii, z kodem i wnioskami. Reszta pod tagiem use-case.

Dwadzieścia sekretów i jedno pytanie, na które nie umiałem odpowiedzieć

Rutynowy przegląd konfiguracji Gitei. Wchodzę w ustawienia organizacji, zakładka Secrets, i patrzę na listę: REGISTRY_PASSWORD, KUBECONFIG_PROD, ARGOCD_TOKEN, COSIGN_KEY, COSIGN_PASSWORD, VAULT_TOKEN, SONAR_TOKEN i kilkanaście pomniejszych. Klasyka. Wszystko poukładane, wszystko opisane, nic nie leży w repo.

I wtedy przychodzi pytanie, które sam sobie zadaję i na które nie mam odpowiedzi: kiedy ostatnio któryś z nich był rotowany?

Nie wiem. Gitea nie pokazuje daty utworzenia sekretu. Wiem tyle, że COSIGN_KEY powstał, gdy zaczynaliśmy podpisywać obrazy, czyli gdzieś półtora roku temu. Że REGISTRY_PASSWORD to robot account z opcją Never Expire, bo tak było najprościej. Że KUBECONFIG_PROD zawiera token service accounta, który technicznie może wszystko w namespace, i leży w kilku miejscach naraz: w Gitei, w moim ~/.kube/config i w jakimś starym wątku na Slacku, do którego bałem się zajrzeć.

Drugie pytanie jest gorsze: kto tego użył wczoraj? Log Gitei powie mi, że job się odpalił. Registry powie, że robot account wypchnął obraz. Ale czy to był mój pipeline, czy ktoś, kto wykradł hasło robota trzy miesiące temu i od tamtej pory grzecznie pushuje obrazy - tego nie wie nikt. Bo hasło nie ma tożsamości. Hasło to tylko dowód na to, że ktoś zna hasło.

To jest moment, w którym warto przestać myśleć o rotacji, a zacząć o tożsamości. Pisałem wcześniej o Security Plane i o tym, że tożsamość, sekrety i policy to trzy filary jednej konstrukcji. Ten tekst jest zejściem z poziomu wzorca na poziom komend - co konkretnie trzeba postawić, żeby ten pierwszy filar zaczął nieść pozostałe. I tu wchodzi SPIFFE.

Co SPIFFE właściwie proponuje

SPIFFE (Secure Production Identity Framework for Everyone) to nie narzędzie, tylko zestaw otwartych standardów. Projekt jest w CNCF na poziomie graduated, razem z referencyjną implementacją SPIRE. Pomysł ma trzy elementy i wszystkie trzy są proste.

SPIFFE ID to nazwa workloadu w formie URI: spiffe://eiac.dev/ci/runner/builder. Nie IP, nie hostname, nie login. Nazwa, która przeżywa restart poda i przeniesienie na inny węzeł.

SVID (SPIFFE Verifiable Identity Document) to kryptograficzny dokument potwierdzający ten ID. W dwóch wariantach: X.509-SVID, czyli certyfikat z SPIFFE ID w polu SAN URI, oraz JWT-SVID, czyli token, w którym sub to SPIFFE ID. Domyślne czasy życia w SPIRE: 1 godzina dla X.509 i 5 minut dla JWT. Rotacja leci automatycznie po połowie czasu życia, bez restartu workloadu.

Workload API to lokalny gniazdo uniksowe, z którego workload pobiera swój SVID - i o to chodzi w całej sprawie: nie podaje przy tym żadnego credentiala. Nie ma hasła do pobrania hasła. Zamiast tego agent SPIRE patrzy, kto właściwie puka do gniazda: jaki to proces, w jakim kontenerze, w jakim podzie, z jakim service accountem. To się nazywa attestation i jest sercem całej konstrukcji.

SPIRE Server (trust domain: eiac.dev) kotwica zaufania + rejestr entries node attestation SPIRE Agent na węźle k8s_psat (pod) · x509pop / tpm_devid (VM) workload attestation workload puka do Workload API (UDS, bez credentiala) selektory: k8s:sa · k8s:pod-label · unix:uid · docker:label X.509-SVID (1h) · JWT-SVID (5m)
Cała sztuczka polega na tym, że strzałki idą w dół, a nie w górę. Workload niczego nie przedstawia - to platforma poświadcza, kim on jest.

Różnica wobec sekretu jest zasadnicza. Sekret odpowiada na pytanie „czy znasz hasło”. SVID odpowiada na pytanie „czy jesteś tym procesem, w tym podzie, z tym service accountem, na tym węźle”. Pierwszego da się ukraść i użyć gdziekolwiek. Drugiego nie, bo jest związany z miejscem, w którym powstał, i wygasa, zanim zdążysz go gdziekolwiek przenieść.

Inwentarz: co konkretnie ma zniknąć

Zanim cokolwiek postawimy, robimy spis. To jest ten sam ruch, co przy wdrażaniu LLM w legacy - najpierw wypisz, co masz, potem myśl o narzędziu. Poniżej mój realny pipeline eiac.dev z mapowaniem na mechanizm SPIFFE.

Sekret dziśCo robiCzym zastępujemyTrudność
REGISTRY_PASSWORD (robot, Never Expire)push obrazuJWT-SVID -> własny token server -> krótkożyciowy token OCIśrednia
COSIGN_KEY + COSIGN_PASSWORDpodpis obrazucosign keyless, JWT-SVID -> prywatne Fulciowysoka
VAULT_TOKEN / AppRolesekrety aplikacyjneX.509-SVID -> Vault auth cert po SAN URIniska
KUBECONFIG_PRODkubectl apply z CIusuwamy przez GitOps: CI nie dotyka klastraniska
ARGOCD_TOKENtrigger syncausuwamy: ArgoCD sam pilnuje reponiska

Ostatnie dwa wiersze są najważniejsze i najmniej efektowne. Najtańszy sposób na wyeliminowanie sekretu to zlikwidowanie potrzeby jego istnienia. Jeśli pipeline nie deployuje, tylko wypycha commit z nowym tagiem obrazu do repo gitops, a ArgoCD sam pilnuje stanu klastra, to KUBECONFIG_PROD przestaje być potrzebny bez żadnego SPIFFE. Zanim zaczniesz stawiać SPIRE, przejrzyj listę pod tym kątem. U mnie w ten sposób odpadły dwa sekrety z pięciu.

Etap 1: runner dostaje tożsamość

Tu jest cała trudność i tu leży prawdziwy koszt wdrożenia. Wszystko dalsze jest łatwe, o ile ten etap wyjdzie.

Problem sprowadza się do pytania: skąd SPIRE ma wiedzieć, że proces, który puka do Workload API, to naprawdę runner budujący projekt X, a nie cokolwiek innego na tej samej maszynie. Odpowiedź zależy od tego, gdzie ten runner mieszka, i różnica jest jakościowa, nie kosmetyczna.

Wariant A: runnery jako pody na Kubernetes

To ścieżka bez pisania kodu. Node attestation robi plugin k8s_psat: agent SPIRE czyta projected service account token ze swojego poda, a serwer weryfikuje go przez TokenReview API klastra. Klucz nigdy nie opuszcza węzła, nie ma nic do wykradzenia.

Workload attestation robi plugin k8s: agent wyciąga identyfikator poda z cgroup procesu i pyta kubelet, co to za pod. Wpis w rejestrze wygląda tak:

spire-server entry create \
  -parentID  spiffe://eiac.dev/spire/agent/k8s_psat/prod/<node-uid> \
  -spiffeID  spiffe://eiac.dev/ci/runner/builder \
  -selector  k8s:ns:gitea-runners \
  -selector  k8s:sa:act-runner \
  -selector  k8s:pod-label:eiac.dev/role:builder

Do poda runnera montujesz gniazdo przez SPIFFE CSI Driver, żeby nie potrzebować hostPath:

volumes:
  - name: spiffe-workload-api
    csi:
      driver: csi.spiffe.io
      readOnly: true

Jedna pułapka warta zapamiętania: katalog z gniazdem po stronie DaemonSetu agenta musi być hostPath, nie emptyDir. Przy emptyDir restart poda agenta rozwala bind mounty we wszystkich workloadach i dostajesz cichą awarię, której nie widać w logach agenta.

Wariant B: runnery na VM lub bare metal

Tu robi się ciekawiej, bo oficjalnej ścieżki dla efemerycznych runnerów CI po prostu nie ma. Sprawdziłem to, zanim zacząłem cokolwiek budować. Był konkretny wniosek o generyczny node attestor konsumujący JWT (spire#5647) - zamknięty w grudniu 2024 bez implementacji, z rekomendacją maintainerów, żeby użyć x509pop. Warto o tym wiedzieć, zanim spędzi się dzień na szukaniu pluginu, którego nie ma.

Realne opcje dla VM:

PluginMechanizmOcena
x509popcert z wewnętrznego PKI + challenge-responsenajlepszy kompromis, klucz nie opuszcza maszyny
tpm_deviddowód rezydencji klucza w TPM 2.0najmocniejszy, wymaga provisioningu LDevID
aws_iid / gcp_iitinstance identity document z IMDSwygodny w chmurze, ale TOFU: atestacja raz na węzeł
join_tokenjednorazowy pre-shared key, domyślnie ważny 600sto nadal sekret, tylko krótszy

Ten ostatni wiersz to najczęstsza pułapka w tym temacie. join_token bywa reklamowany jako rozwiązanie dla efemerycznych runnerów, ale on niczego nie eliminuje - zamienia jeden długożyciowy bearer na krótkożyciowy bearer, który i tak trzeba jakoś dostarczyć do runnera. Jako bootstrap do PoC jest w porządku. Jako docelowa architektura to samooszukiwanie.

Uczciwy wniosek z porównania: na k8s SPIFFE dowozi zero-secrets naprawdę, na VM dowozi je pod warunkiem, że masz wewnętrzne PKI albo TPM. Jeśli nie masz ani jednego, ani drugiego, to zaczynasz nie od SPIRE, tylko od PKI - i to jest osobny projekt, nie krok w tym.

Etap 2: push do registry bez hasła

Tu potrzebna jest uczciwość, bo w tym miejscu marketing zwykle rozjeżdża się z protokołem.

Specyfikacja token auth dla OCI registry nie przyjmie JWT-SVID jako bearera. Registry ufa wyłącznie tokenom podpisanym kluczem zakotwiczonym w jego własnej konfiguracji (auth.token.rootcertbundle) i mającym właściwe claimy access. SVID nie ma tej struktury i mieć jej nie będzie.

Harbor też nie pomoże wprost. Jego wsparcie OIDC dla docker login działa przez tak zwany CLI secret, bo - cytując dokumentację - „the Docker and Helm CLIs cannot handle redirection for OIDC”. Z punktu widzenia Dockera to nadal hasło, tyle że mintowane przez Harbora. mTLS ze SPIFFE do API Harbora nie istnieje natywnie i nie znalazłem nawet feature requestu w tę stronę - to wniosek z braku dokumentacji, więc traktuj go jako „nie znalazłem”, nie jako „nie da się”.

Realna ścieżka jest inna i wynika z jednego zdania w specyfikacji: token server jest twój, a sposób uwierzytelnienia klienta wobec niego spec zostawia poza zakresem. Czyli:

runner --(JWT-SVID lub mTLS na X.509-SVID)--> własny token server
                                                    |
                             waliduje wobec JWKS SPIRE, mapuje SPIFFE ID na scope
                                                    |
                                        krótkożyciowy JWT podpisany kluczem registry
                                                    |
                                              docker push

I tu pada wniosek, który uważam za najważniejszy w całym temacie. docker push na końcu i tak dostaje hasło. Zawsze. Protokół OCI kończy się bearerem i nic tego nie zmieni. Różnica polega na tym, że to hasło jest wyprowadzone z tożsamości workloadu i żyje kilkanaście minut, zamiast leżeć w konfiguracji CI przez półtora roku.

„Zero-secrets” nie znaczy więc „bez credentiali”. Znaczy bez credentiali trwałych, przenośnych i niepowiązanych z tożsamością. Kto obiecuje co innego, sprzedaje slajd, nie architekturę.

Etap 3: podpisywanie obrazów tożsamością buildera

Ten etap jest najładniejszy koncepcyjnie, bo tu SPIFFE nie zastępuje sekretu innym mechanizmem - on usuwa klucz całkowicie.

Cosign od wersji 2.0 domyślnie robi keyless: generuje efemeryczną parę kluczy w pamięci, wymienia token OIDC na krótkożyciowy certyfikat w Fulcio, podpisuje, wrzuca wpis do Rekora i wyrzuca klucz prywatny. Certyfikat żyje dokładnie 10 minut. Nie ma czego rotować, bo nie ma czego przechowywać.

Fulcio ma natywne wsparcie dla SPIFFE - typ issuera spiffe, który parsuje sub jako SPIFFE ID i osadza go w SAN URI certyfikatu. Cosign ma z drugiej strony wbudowanego providera SPIFFE, który sam woła Workload API. Cały krok w pipeline wygląda tak:

export SPIFFE_ENDPOINT_SOCKET=/run/spire/sockets/agent.sock

cosign sign \
  --oidc-provider spiffe \
  --fulcio-url https://fulcio.internal.eiac.dev \
  --rekor-url  https://rekor.internal.eiac.dev \
  --yes registry.eiac.dev/app@sha256:<digest>

Zero flag z kluczami. Zero zmiennych z hasłem. Weryfikacja po tożsamości buildera:

cosign verify \
  --certificate-identity     "spiffe://eiac.dev/ci/runner/builder" \
  --certificate-oidc-issuer  "https://oidc.internal.eiac.dev" \
  registry.eiac.dev/app@sha256:<digest>

Teraz twarde ograniczenie, które trzeba znać, zanim się to zaplanuje. Publiczne Fulcio nie przyjmie twojego JWT-SVID. Sprawdziłem konfigurację na żywym endpoincie fulcio.sigstore.dev/api/v2/configuration: nie ma tam ani jednego issuera typu spiffe, wszystkie mają puste spiffeTrustDomain. Publiczna instancja zna tylko dostawców typu email, ci-provider (GitHub Actions, GitLab, CircleCI, Buildkite), chainguard-identity oraz wildcardy typu kubernetes dla EKS/GKE/AKS. Prywatna trust domain wymaga rejestracji issuera przez PR do repo Fulcio i to nie jest realna ścieżka dla wewnętrznej domeny.

Konsekwencja: keyless ze SPIFFE oznacza własny stos Sigstore. Najprościej przez sigstore/scaffolding - Fulcio, CT log, Rekor, TSA i wygenerowany root TUF. Jako issuera wystawiasz SPIRE OIDC Discovery Provider, który serwuje JWKS pod /keys (nie pod /.well-known/jwks.json - to jest drobiazg, na którym łatwo stracić godzinę).

Do tego trzeba dopisać uczciwie: to jest kilka usług do utrzymania. Dla jednego repo nie ma sensu. Dla platformy z kilkudziesięcioma serwisami i wymogiem SLSA - ma.

Etap 4: sekrety aplikacyjne

To najprostszy etap i dobre miejsce, żeby zacząć wdrożenie, bo daje efekt przy najmniejszym nakładzie.

Vault ma auth method cert, który działa ze SVID-em wprost. Jako CA podajesz bundle SPIRE, a dopasowanie robisz po SAN URI:

spire-server bundle show -format pem > bundle.pem

vault write auth/cert/certs/ci-builder \
    [email protected] \
    allowed_uri_sans="spiffe://eiac.dev/ci/runner/*" \
    token_policies="ci-read" \
    token_ttl=15m token_type=batch

Trzy rzeczy, na które trzeba uważać, bo każda z nich to cicha dziura w bezpieczeństwie:

Pusty allowed_uri_sans wpuszcza wszystko. Kod Vaulta w takim wypadku zwraca true bez sprawdzania czegokolwiek. W połączeniu z bundlem SPIRE jako CA oznacza to, że każdy workload w trust domain dostaje polityki tej roli. To najbardziej prawdopodobna misconfiguracja w całym wzorcu.

Gwiazdka łapie także ukośnik. Vault używa tu biblioteki go-glob, więc spiffe://eiac.dev/ci/* matchuje też spiffe://eiac.dev/ci/a/b/c. Nie ma wildcarda ograniczonego do jednego segmentu ścieżki. Projektuj przestrzeń nazw SPIFFE tak, żeby ta gwiazdka nie bolała.

Odnowienie tokenu nie zadziała po rotacji SVID-a. Vault przy renew porównuje subject_key_id certyfikatu z tym z logowania. Po rotacji (mniej więcej co pół godziny, bo SVID żyje godzinę, a SPIRE rotuje po połowie życia z niewielkim jitterem) para kluczy jest nowa i renew failuje. Poprawna odpowiedź to token_type=batch, token_ttl krótszy niż TTL SVID-a i traktowanie każdej rotacji jak świeżego logowania. Nie disable_binding=true, choć to pierwsza rzecz, którą podpowie wyszukiwarka.

Alternatywą jest SPIKE - store sekretów zbudowany od zera na SPIFFE, bez unseal keys, z root key wyłącznie w pamięci i shardami Shamira w komponentach Keeper. Ładna architektura, ale README mówi wprost: dojrzałość „Development”, „use in critical systems is not advised at this time”. Ostatnie wydanie v0.8.0 z listopada 2025 miało breaking changes w API. Do laba tak, do produkcji jeszcze nie.

Etap 5: deploy, czyli sekret, który po prostu kasujemy

Najlepszy sekret to ten, którego nie ma. Pipeline nie dostaje dostępu do klastra w ogóle - kończy się na wypchnięciu commita z nowym digestem obrazu do repo gitops, a resztę robi ArgoCD, który już jest w klastrze i nie potrzebuje żadnego tokenu z zewnątrz.

Domknięciem jest polityka admisji, która sprawdza, czy obraz został podpisany przez właściwą tożsamość. Wtedy nawet przejęty runner niewiele zdziała, bo jego SPIFFE ID nie przejdzie weryfikacji:

apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
  name: require-builder-svid
spec:
  mode: enforce
  images:
    - glob: "registry.eiac.dev/apps/**"
  authorities:
    - name: spire-builder
      keyless:
        trustRootRef: eiac-sigstore
        identities:
          - issuer: https://oidc.internal.eiac.dev
            subject: spiffe://eiac.dev/ci/runner/builder

To działa, bo cosign przy dopasowywaniu identity zbiera wszystkie SAN-y certyfikatu włącznie z URI - a SPIFFE ID siedzi właśnie w URI SAN. Alternatywnie to samo da się zrobić przez Kyverno verifyImages, z tym że przy prywatnym Fulcio bez Rekora Kyverno wymaga podania roots - inaczej webhook odrzuci politykę.

I tu domyka się ładna pętla: SPIRE potrafi też użyć podpisu jako selektora przy workload attestation (k8s:image-signature-subject, dostępny po włączeniu bloku sigstore na atestorze), czyli podpis z etapu 3 może być warunkiem wydania tożsamości aplikacji w etapie 1. Tożsamość poświadcza podpis, podpis poświadcza tożsamość.

runner: spiffe://eiac.dev/ci/runner/builder zero sekretow w konfiguracji CI 1. push obrazu JWT-SVID → token server → token OCI na 15 min 2. podpis cosign --oidc-provider spiffe → cert Fulcio na 10 min 3. sekrety aplikacji X.509-SVID → Vault cert match po SAN URI 4. commit digestu do repo gitops CI nie ma dostepu do klastra: KUBECONFIG i ARGOCD_TOKEN znikaja ArgoCD → klaster → ClusterImagePolicy wpuszcza tylko obrazy podpisane przez SPIFFE ID buildera tozsamosc poswiadcza podpis, podpis poswiadcza tozsamosc
Cztery kroki, żaden nie sięga do konfiguracji sekretów CI. Najdłuższy żyjący credential w tym obrazku to token OCI: piętnaście minut.

Wnioski

1. Zero-secrets to skrót myślowy, nie stan. Credentiale nie znikają - one przestają być trwałe, przenośne i anonimowe. Token do registry nadal istnieje, tylko żyje kwadrans i da się powiedzieć, który pod go dostał. To jest cała różnica i ona jest wystarczająco duża, żeby warto było się ruszyć.

2. Punkt ciężkości przesuwa się na attestation. Przestajesz pilnować dwudziestu sekretów, zaczynasz pilnować jednego pytania: skąd platforma wie, kim jest ten proces. To lepszy problem, bo ma jedną odpowiedź zamiast dwudziestu, ale nie jest to problem darmowy.

3. Kubernetes zmienia wszystko. Na k8s ścieżka jest gotowa: k8s_psat plus workload attestation po service accountcie plus CSI driver. Na VM oficjalnej ścieżki dla efemerycznych runnerów nie ma i nie będzie - wniosek o JWT node attestor zamknięto bez implementacji. Zostaje x509pop z wewnętrznym PKI albo tpm_devid, czyli trzeba mieć fundament, którego wiele zespołów nie ma.

4. join_token to pułapka. Wygląda jak rozwiązanie dla efemerycznych runnerów, a jest krótkożyciowym bearerem, który trzeba dostarczyć z zewnątrz. Do bootstrapu tak, jako architektura docelowa - nie.

5. Kolejność wdrożenia jest odwrotna do atrakcyjności. Najefektowniejszy jest keyless signing, a jest najdroższy: wymaga własnego stosu Sigstore, bo publiczne Fulcio nie zna twojej trust domain. Zacznij od skasowania sekretów, których nie potrzebujesz (GitOps zamiast kubectl z CI), potem Vault po SVID, dopiero na końcu registry i podpisy.

6. Największe ryzyko to nie SPIRE, tylko konfiguracja po drugiej stronie. Pusty allowed_uri_sans w Vaulcie wpuszcza całą trust domain. Gwiazdka w go-globie łapie ukośniki. Renew tokenu failuje po rotacji SVID-a. Wszystkie trzy to ciche awarie, które nie krzyczą w logach - i wszystkie trzy trzeba przetestować, zanim to wejdzie na produkcję.

7. Wersje trzeba przypiąć. Cosign v3 przeniósł adresy usług do konfiguracji dystrybuowanej przez TUF, a flagi --fulcio-url i --rekor-url zostały zdeprecjonowane i ukryte - formalnie nadal działają, ale zniknęły z pomocy i z wygenerowanej dokumentacji. Materiał w sieci opisuje głównie v2. Przypnij wersję w pipeline i sprawdź --help na swoim buildzie.

Praktyczna puenta: SPIFFE nie jest projektem na tydzień i nie zwraca się przy jednym repo. Zwraca się wtedy, gdy masz platformę, wiele zespołów i pytanie „kto tego użył wczoraj” zaczyna być pytaniem audytowym, a nie retorycznym. Do tego momentu wystarczy dobry GitOps i higiena sekretów. Ale warto wiedzieć, dokąd się idzie, zanim lista w zakładce Secrets urośnie do pięćdziesięciu pozycji.

Następny krok u mnie: lab spiffe-zero-secrets, w którym cała ta konstrukcja stoi na kind i da się ją odpalić jedną komendą. Plan wykonawczy jest już rozpisany; wyniki, razem z tym, co się nie udało, opiszę osobno.