İmzalama ve Provenance: Sigstore, Artifact Attestation'ları ve SLSA v1.2
Bir image imzası içeriğin değişmediğini ve kimin imzaladığını, provenance ise hangi repo, workflow ve commit'ten üretildiğini kanıtlar; ikisi de ancak deploy anında doğrulanırsa işe yarar. Keyless Sigstore imzasını ve GitHub attestation'larını gerçek release'ler üzerinde doğruluyor, Bookstore image'ına SBOM attestation'ı bağlıyor, Kyverno ile imzasız ve tag'li image'ı reddediyor ve pipeline'ı SLSA v1.2'ye göre değerlendiriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; imzalama ve doğrulama cosign 3.1.4, Kyverno CLI 1.20.0 ve policy-controller tester 0.15.1 ile, workflow'lar actionlint 1.7.12 ile doğrulanmıştır.
Önce Tahmin Et
Bookstore'un Kubernetes cluster'ında ghcr.io/bookstore/bookstore-api:1.4.0 çalışıyor. Registry'ye yazma yetkisi olan bir token sızıyor ve biri aynı tag'e kendi image'ını push ediyor. Pod yeniden başladığında cluster hangi image'ı çeker? Image'ı imzalıyor olsaydın bu durumu kim, hangi anda fark ederdi? Ve imzalama anahtarı pipeline'da bir secret olarak dursaydı, o secret sızdığında imza neyi kanıtlardı?
Bu yazıda artifact'ın kimden ve nereden geldiğini kanıtlamayı kuruyoruz: Sigstore ile keyless imzalama, GitHub artifact attestation'ları ve SLSA (Supply-chain Levels for Software Artifacts) provenance'ı, geçen yazıdaki SBOM'u (yazılımın içerdiği bileşenlerin listesi) image'a bağlamak, deploy anında imzasız image'ı reddeden bir admission policy ve SLSA v1.2'ye göre Bookstore pipeline'ının nerede durduğu.
İmza Neyi Kanıtlar
Bir image imzası üç soruya cevap verir: bu içerik imzalandıktan sonra değişti mi (bütünlük), imzayı kim attı (kimlik) ve hangi koşullarda (hangi repo, hangi workflow, hangi commit). İlk ikisi klasik imzalamanın da yaptığı şey. Üçüncüsü provenance: artifact'ın nasıl üretildiğine dair imzalı bir kayıt. "Bu image bizim pipeline'ımızdan mı çıktı?" sorusu aslında üçüncü soru; bir geliştiricinin laptop'unda, doğru anahtarla imzalanmış bir image de "bizim anahtarımızla imzalı"dır ama pipeline'dan çıkmamıştır.
İmzanın değeri tamamen doğrulamaya bağlı. Kimsenin kontrol etmediği bir imza yalnız log'da duran bir satırdır. Bu yüzden yazının yarısı imzalamak, yarısı doğrulamak üzerine.
Keyless İmzalama: Anahtar Yerine Kimlik
Klasik imzalamada bir private key saklarsın ve onu korumak senin işindir. Sigstore'un keyless akışında ise uzun ömürlü bir anahtar yok. İmzalayan süreç, örneğin bir GitHub Actions job'ı, OIDC (OpenID Connect) token'ını Sigstore'un sertifika otoritesi Fulcio'ya gösterir; Fulcio o kimliğe dakikalar süren bir sertifika verir, imza bu sertifikanın geçici anahtarıyla atılır ve kayıt Rekor adlı herkese açık transparency log'a yazılır. Doğrulayan taraf anahtara değil, sertifikadaki kimliğe bakar: hangi OIDC issuer, hangi workflow, hangi ref.
Geçen yazıda Trivy binary'sini böyle doğrulamıştık. Aynı mekanizmayı Trivy'nin container image'ında, beklenen workflow kimliğiyle denedim:
cosign verify ghcr.io/aquasecurity/trivy@sha256:8f6bb947dbc528902e11f2f5be52f7b519f6a39d5d148f41166f2040499768fb \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity "https://github.com/aquasecurity/trivy/.github/workflows/reusable-release.yaml@refs/tags/v0.76.2"
Verification for ghcr.io/aquasecurity/trivy@sha256:8f6bb947dbc528902e11f2f5be52f7b519f6a39d5d148f41166f2040499768fb --
The following checks were performed on each of these signatures:
- The cosign claims were validated
- Existence of the claims in the transparency log was verified offline
- The code-signing certificate was verified using trusted certificate authority certificates
Komutta tag yerine digest kullandım. Tag taşınabilir; doğrulamayı tag'le yapıp image'ı digest'iz çekersen, doğruladığın şeyle çalıştırdığın şey farklı olabilir. Bunu birazdan yerel registry'de göreceğiz.
GitHub Artifact Attestation'ları ve SLSA Provenance
GitHub'ın artifact attestation'ları aynı altyapının üstüne kurulu: workflow, ürettiği artifact'ın digest'ini in-toto formatında bir "statement"a koyar, yanına SLSA provenance'ı (hangi workflow, hangi commit, hangi runner) ekler ve Sigstore ile imzalar. Public repo'larda Sigstore'un public instance'ı, private repo'larda GitHub'ın kendi Sigstore instance'ı kullanılıyor; dokümana göre private ve internal repo'larda bu özellik GitHub Enterprise Cloud gerektiriyor. Gerçek bir örneği inceledim: GitHub CLI'ın v2.104.0 Linux paketi. Attestation'ı GitHub'ın REST (Representational State Transfer) API'sinden digest ile buldum ve içeriğine baktım:
jq -r '.dsseEnvelope.payload' gh-att-1.json | base64 -d \
| jq '{predicateType, builder: .predicate.runDetails.builder.id, commit: .predicate.buildDefinition.resolvedDependencies[0].digest.gitCommit, subjects: (.subject | length)}'
{
"predicateType": "https://slsa.dev/provenance/v1",
"builder": "https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk",
"commit": "3d5a9e355a837f2f5ee78d7484c118cc1342ad1a",
"subjects": 21
}
Tek attestation, release'in 21 dosyasını digest'leriyle kapsıyor ve her birinin hangi commit'ten, hangi workflow ile üretildiğini söylüyor. Bundle'daki sertifikanın geçerlilik süresi şuydu:
notBefore=Nov 12 19:01:55 2026 GMT
notAfter=Nov 12 19:11:55 2026 GMT
On dakika. Bu sertifikanın anahtarı çalınsa bile on dakika sonra işe yaramaz; imzanın o pencerede atıldığını Rekor kaydı kanıtlar. "Uzun ömürlü imzalama anahtarı saklama" sorununun keyless cevabı bu. Normalde doğrulama gh attestation verify ile tek komut; ben gh'yi bu makinede oturum açmadan kullanamadığım için bundle'ı cosign ile doğruladım:
cosign verify-blob-attestation gh_2.104.0_linux_amd64.tar.gz --bundle gh-att-1.json \
--type https://slsa.dev/provenance/v1 \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk
Verified OK
İki negatif deneme yaptım. Beklenen kimliği refs/heads/feature-x olarak yazınca doğrulama reddedildi:
Error: failed to verify certificate identity: no matching CertificateIdentity found, last error: expected SAN value "https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/feature-x", got "https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk"
Aynı attestation'ı başka bir dosyaya karşı denediğimde ise provided artifact digests do not match digests in statement hatası geldi. Biri kimliği, diğeri bütünlüğü kontrol ediyor; ikisi birlikte "bu dosya, şu repo'nun trunk'taki deployment workflow'u tarafından üretildi" cümlesini kanıtlıyor.
Bookstore'un Release Job'ı
Bookstore'da image'ı build eden, push eden, SBOM'unu çıkaran, imzalayan ve attestation'larını üreten adımlar tek bir job'da. Bunun nedeni geçen yazıdaki kural: SBOM ve provenance, imzalanan digest'in aynı build'inden üretilmeli.
name: release-image
on:
push:
branches: [main]
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
attestations: write
artifact-metadata: write
env:
IMAGE: ghcr.io/bookstore/bookstore-api
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: sigstore/cosign-installer@756fe2d284c7292c4e9b62c8a28f1e5629c59532 # v4.1.3
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Build and push
id: push
run: |
docker build -t "$IMAGE:$GITHUB_SHA" .
docker push "$IMAGE:$GITHUB_SHA"
digest=$(docker inspect --format '{{index .RepoDigests 0}}' "$IMAGE:$GITHUB_SHA" | cut -d@ -f2)
echo "digest=$digest" >> "$GITHUB_OUTPUT"
- name: SBOM of the pushed image
uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
with:
image: ${{ env.IMAGE }}@${{ steps.push.outputs.digest }}
format: cyclonedx-json
output-file: bookstore-api-image.cdx.json
- name: Sign the image (keyless)
env:
DIGEST: ${{ steps.push.outputs.digest }}
run: cosign sign --yes "$IMAGE@$DIGEST"
- name: Build provenance attestation
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
- name: SBOM attestation
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.push.outputs.digest }}
sbom-path: bookstore-api-image.cdx.json
push-to-registry: true
id-token: write, keyless imza için OIDC token'ı alabilmenin izni; attestations: write ve artifact-metadata: write ise actions/attest README'sinin istediği izinler. Yetki yalnız bu job'da, workflow seviyesi contents: read; OIDC ve least privilege'ın mekaniği CI/CD Temelleri serisinin "Pipeline Güvenliği ve Secret'lar" yazısında. actions/attest'in v4'ünden beri attest-build-provenance ve attest-sbom bu action'ın üstünde ince bir sarmalayıcı; README yeni kurulumlarda doğrudan actions/attest'i öneriyor. Workflow'u actionlint ile doğruladım ve digest'i çıkaran satırı yerel registry'de çalıştırdım; GitHub üzerinde çalıştırmadım, çünkü keyless imza ve attestation gerçek bir Actions OIDC token'ı istiyor.
Yerelde Denemek: Anahtarla İmza, SBOM Attestation'ı ve Tag Tuzağı
Akışı uçtan uca görmek için kendi registry container'ımı açtım ve OIDC olmadığı için bu denemede bir cosign anahtar çifti kullandım (anahtar çalışma klasöründe kaldı). İmzanın herkese açık Rekor'a gitmemesi için transparency log içermeyen bir signing config verdim; cosign 3'te --tlog-upload=false artık bu config'le birlikte kabul edilmiyor:
IMG=localhost:5005/bookstore-api@sha256:31be559cfe5d4b76434920adb825ee99615c363624466ae715427f87e7ac5bad
cosign signing-config create --out offline-signing-config.json
cosign sign --key cosign.key --signing-config offline-signing-config.json --yes "$IMG"
cosign attest --key cosign.key --signing-config offline-signing-config.json --yes \
--type cyclonedx --predicate bookstore-api-image.cdx.json "$IMG"
SBOM attestation'ını doğrulayıp içindeki statement'a baktım:
cosign verify-attestation --key cosign.pub --insecure-ignore-tlog --type cyclonedx "$IMG" \
| jq -r '.payload' | base64 -d | jq '{predicateType, subject, components: (.predicate.components | length)}'
{
"predicateType": "https://cyclonedx.org/bom",
"subject": [
{
"name": "localhost:5005/bookstore-api",
"digest": {
"sha256": "31be559cfe5d4b76434920adb825ee99615c363624466ae715427f87e7ac5bad"
}
}
],
"components": 390
}
Geçen yazıdaki 390 bileşenli SBOM artık image'ın digest'ine imzalı olarak bağlı; SBOM'u registry'den image'la birlikte alan biri, onun bu image'a ait olduğunu ve değiştirilmediğini doğrulayabilir. Sonra açılıştaki senaryoyu oynadım: image'a fazladan bir dosya ekleyip aynı 1.4.0 tag'ine push ettim. Push başarılı oldu, tag yeni digest'i (sha256:d6217b48...) gösterdi ve tag ile doğrulama şunu döndü:
Error: no signatures found
İmza digest'e bağlı; tag'i taşıyan, imzayı taşıyamaz. Ama bu yalnız doğrulama yapılıyorsa işe yarar: cluster tag'le image çekiyor ve kimse kontrol etmiyorsa, yeni image sessizce çalışır.
Deploy Anında Doğrulamak: Admission Policy
Doğrulamanın en güvenilir yeri cluster'ın kapısı: Kubernetes, bir Pod oluşturulmadan önce admission webhook'larına sorar ve policy motoru image imzasını kontrol edip isteği reddedebilir. Mekanizma Kubernetes serisinin "Kubernetes Güvenliği: ServiceAccount, RBAC, Pod Security Standards, securityContext ve NetworkPolicy" yazısındaki admission kavramının üstüne oturuyor. Kyverno 1.20'nin CLI'ıyla bir ImageValidatingPolicy'yi cluster olmadan Pod manifest'lerine karşı çalıştırdım. Bookstore'un politikası, yerel denemedeki public key'e güveniyor (key'i burada kısalttım):
apiVersion: policies.kyverno.io/v1
kind: ImageValidatingPolicy
metadata:
name: bookstore-signed-images
spec:
validationActions: [Deny]
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
resources: ["pods"]
operations: ["CREATE", "UPDATE"]
matchImageReferences:
- glob: "localhost:5005/bookstore-api*"
attestors:
- name: pipeline
cosign:
key:
data: |
-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----
ctlog:
insecureIgnoreTlog: true
validations:
- expression: >-
images.containers.map(image, verifyImageSignatures(image, [attestors.pipeline])).all(e, e > 0)
message: "Image is not signed by the Bookstore pipeline"
Üç Pod denedim. İmzalı digest'le pass: 1. Fazladan dosyalı image'ın digest'iyle:
policy bookstore-signed-images -> resource bookstore/Pod/bookstore-api failed:
1 - bookstore-signed-images Image is not signed by the Bookstore pipeline; verification details: image "localhost:5005/bookstore-api@sha256:d6217b48e377980adcb625c89029560e3f352c4049b20878d83d5310bd75e8e3", attestor "pipeline": failed to verify cosign signatures: no signatures found
pass: 0, fail: 1, warn: 0, error: 0, skip: 0
Tag'li Pod ise imzaya bakılmadan reddedildi: image localhost:5005/bookstore-api:1.4.0 does not have a digest. Production'da attestor key yerine keyless olur ve issuer ile workflow kimliğini ister. Bunu Trivy'nin gerçek image'ında denedim: subjectRegExp Trivy'nin release workflow'unu gösterdiğinde Pod geçti, aynı regex'i refs/heads/main olarak yazınca policy expected SAN value to match regex hatasıyla reddetti. Burada insecureIgnoreTlog yalnız yerel denemenin offline imzası için var; keyless politikada Rekor kaydı doğrulanır.
Bir uyumluluk tuzağı da buldum. Aynı denemeyi Sigstore'un policy-controller'ının tester'ıyla (0.15.1) yaptığımda imzalı image için no signatures found döndü. cosign 3, imzayı varsayılan olarak yeni bundle formatında, OCI (container image ve runtime formatları için açık standart) referrer'ı olarak yazıyor; bu tester ise imzayı ancak eski .sig tag formatında da imzaladığımda buldu, Trivy'nin public image'ında da aynı sonucu verdi. Kyverno 1.20 iki formatı da doğruladı. İmzalayan araçla doğrulayan aracın aynı formatı konuştuğunu, policy'yi açmadan önce gerçek bir image'la test et. Bir not daha: Kyverno 1.20 eski kyverno.io/v1 ClusterPolicy türünü artık çalıştırmıyor ve CEL (Kubernetes policy'lerinde kullanılan ifade dili) tabanlı yeni türlere geçmeni istiyor; internetteki örneklerin çoğu hâlâ eski formatta.
SLSA v1.2: Bookstore Hangi Seviyede?
SLSA, supply chain güvencesini seviyelerle tarif eden bir çerçeve; CI/CD Temelleri serisinde kısaca girmiştik. Kasım 2025'te yayımlanan v1.2, mevcut Build track'in yanına Source track'i ekledi. Build track'te L1 provenance'ın var olmasını, L2 provenance'ın hosted bir build platformu tarafından üretilip imzalanmasını, L3 build'lerin birbirinden izole olmasını ve imzalama sırrının kullanıcı adımlarından erişilemez olmasını istiyor. Source track'te L1 version control, L2 kesintisiz ve değiştirilemez geçmiş ile her revizyon için source provenance, L3 isimli branch'lerde sürekli uygulanan teknik kontroller, L4 korunan branch'lerde iki kişilik review.
| Track | Bookstore bugün | Neden | Sonraki adım |
|---|---|---|---|
| Build | L2 | GitHub-hosted runner, actions/attest ile imzalı provenance | L3: build'i reusable workflow'a taşıyıp doğrulamada --signer-workflow istemek |
| Source | L1 | Kod Git'te, geçmiş GitHub'da | Branch ruleset'leri ve zorunlu iki onay L3/L4'ün kontrolleri; L2'nin source provenance'ı ayrıca gerekiyor |
GitHub'ın dokümanı varsayılan artifact attestation'larını SLSA v1.0 Build L2 olarak tanımlıyor ve L3 için build'i attestation'ı da üreten bir reusable workflow'da yapmayı öneriyor; Build track'in seviyeleri v1.2'de aynı kaldı. Source track için kaynak kontrol sisteminin her revizyonda provenance üretmesi gerekiyor; GitHub'ın bunu yerleşik olarak sağlayıp sağlamadığını doğrulayamadım, bu yüzden Bookstore'u L1'de sayıyorum. Commit imzalama bu track'le ilişkili ama aynı şey değil; mekaniği Git & GitHub serisinin "Git Hook'ları ve Commit İmzalama: pre-commit'ten SSH Signature'ına" yazısında.
Paket ekosistemlerinde de aynı fikir var. NuGet.org her pakete bir repository imzası koyuyor; dotnet nuget verify --all ile Microsoft.AspNetCore.OpenApi 10.0.15'in hem author hem repository imzası Successfully verified package ile doğrulandı ve trustedSigners ile restore'da yalnız güvendiğin imzacıları kabul edebilirsin. npm tarafında provenance ve trusted publishing Frontend Güvenliği serisinin "npm Supply Chain'i Sertleştirmek" yazısında.
Üretimde Dikkat
Doğrulamayı politika olarak başlat, sonra zorla. Kyverno'da validationActions: [Audit] ile bir hafta çalıştırıp hangi image'ların imzasız olduğunu gör; sistem image'ları ve üçüncü taraf chart'lar genelde ilk sürprizdir. Liste temizlenince Deny'a geç.
Kimliği dar tut. Keyless politikada issuer'ı tam, workflow kimliğini repo ve ref ile sınırla; .* gibi geniş bir regex, GitHub'daki herhangi bir repo'nun herhangi bir workflow'unu kabul eder.
Admission webhook'u bir bağımlılık. Policy motoru Rekor'a, registry'ye ve Fulcio'nun kök sertifikalarına erişemiyorsa ne yapacağını (failurePolicy) bilinçli seç; doğrulanamayan image'ı geçirmek güvenliği, reddetmek erişilebilirliği riske atar.
Private repo'larda attestation'lar GitHub'ın kendi Sigstore instance'ıyla imzalanıyor ve public bir transparency log'a gitmiyor; doğrulayan tarafın bu güven kökünü tanıması gerekiyor.
Sık Düşülen Tuzaklar
Birincisi, imzalayıp doğrulamamak. İmza, deploy'u yapan sistem onu kontrol ettiğinde değer kazanır.
İkincisi, tag ile doğrulayıp tag ile çalıştırmak. Doğrulama ve çalıştırma aynı digest'e bakmalı; Kyverno'nun tag'li Pod'u reddetmesi bu yüzden iyi bir varsayılan.
Üçüncüsü, uzun ömürlü bir imzalama anahtarını pipeline secret'ı olarak saklamak. O secret sızdığında saldırgan "pipeline'dan çıkmış" görünen image'lar üretebilir; keyless akışta çalınacak kalıcı bir anahtar yok.
Dördüncüsü, doğrulama kimliğini fazla geniş yazmak. Yalnız issuer'ı kontrol eden bir politika, GitHub Actions'ta çalışan herkese güvenir.
Beşincisi, imzalayan ve doğrulayan araçların format uyumunu test etmemek. Bu yazıda policy-controller tester, cosign 3'ün varsayılan imza formatını görmedi.
Küçük Bir Deneme
★☆☆ Kullandığın bir açık kaynak aracın container image'ını ya da release dosyasını cosign ile doğru kimlikle doğrula, sonra kimliği bilerek yanlış yazıp hata mesajını oku.
★★☆ Yerel bir registry container'ı aç, kendi image'ını offline signing config ile imzala ve SBOM'unu attestation olarak ekle. Aynı tag'e başka bir image push edip tag ve digest ile doğrulamanın farkını gör.
★★★ Kyverno CLI ile bir ImageValidatingPolicy yaz; imzalı digest, imzasız digest ve tag'li üç Pod manifest'iyle test et. Sonra policy'yi keyless'a çevirip workflow kimliğini kendi repo'na ve main branch'ine daralt.
Secret'tan imzaya kadar bütün kontrolleri tek tek kurduk. Serinin son yazısında hepsini Bookstore'un tek bir workflow'unda birleştiriyoruz: PR'da hızlı, main'de derin kontroller, gereksiz uyarı üretmeyen gate'ler, süresi dolan istisnalar ve bu programın gerçekten işe yarayıp yaramadığını gösteren metrikler.