GCP Cloud Build: SLSA Provenance, Artifact Registry Signing, and Binary Authorization
The Problem
Most organisations adopting Google Cloud Platform reach for Cloud Build for CI/CD without understanding that Cloud Build’s SLSA provenance generation is opt-in, and that the provenance it generates is useless unless something downstream validates it. The gap between “we generate provenance” and “we enforce provenance at deployment time” is where supply-chain attacks land.
The concrete gap looks like this: Cloud Build produces a build_result.json containing SLSA-format provenance signed by Google’s Cloud Build service account. If no Binary Authorization policy requires attestation verification, that provenance sits in Artifact Registry as metadata but nothing checks it before a container is scheduled to GKE. An attacker who can write to Artifact Registry (via a compromised CI service account, a misconfigured registry IAM binding, or an injected image layer) can push a malicious image without provenance and have it run in production.
The three-layer defence is:
-
Cloud Build provenance generation: Cloud Build’s built-in SLSA provenance records the build steps, the source commit, the builder identity, and the artifact digest in a signed attestation stored in Artifact Registry.
-
Artifact Registry signing via Cloud KMS: Container images are signed with a Cloud KMS asymmetric key at build time using
cosign. The signature is stored in Artifact Registry alongside the image. -
Binary Authorization: A GKE admission controller that checks, before scheduling any pod, that the container image referenced has attestations matching a configured policy. Without a matching attestation, the pod is rejected.
Without all three layers, the supply chain has gaps. Provenance without enforcement is theatre. Enforcement without provenance is fragile (signatures can be generated by any process with KMS access, not just Cloud Build). The combination provides cryptographic proof of build provenance at deployment time.
Target systems: GCP projects using Cloud Build for container builds; GKE clusters (Autopilot or Standard) with Binary Authorization enabled; Artifact Registry as the image store; Cloud KMS for signing key management.
Threat Model
1. Compromised CI service account (attacker with roles/cloudbuild.builds.editor or roles/storage.objectAdmin). Objective: push a backdoored container image to Artifact Registry without triggering the CI pipeline; bypass Binary Authorization by generating a forged signature. Impact: malicious image runs in production on next deployment.
2. Malicious build step injection (attacker with write access to cloudbuild.yaml). Objective: inject a build step that exfiltrates source code or modifies the compiled binary before the signature is applied. Impact: signed provenance covers the tampered binary; downstream validators accept it.
3. Registry IAM misconfiguration (overly-permissive Artifact Registry IAM). Objective: push an image to a production repository using a developer service account that has been compromised. Impact: un-attested image bypasses Binary Authorization if policy is permissive, or triggers a policy alert with no automated block.
4. KMS key exfiltration (attacker with roles/cloudkms.cryptoKeyEncrypterDecrypter). Objective: extract or use the signing key to generate valid attestations for arbitrary images. Impact: Binary Authorization enforcement is defeated; no upstream build provenance required.
Blast radius without hardening: a single compromised service account with Artifact Registry write access can push an un-attested image, and without Binary Authorization the image runs in production silently.
Hardening Configuration
Layer 1: Cloud Build SLSA Provenance
Cloud Build generates SLSA provenance automatically when builds use the managed pool and the project has provenance enabled:
# Enable SLSA provenance for the project
gcloud projects add-iam-policy-binding PROJECT_ID \
--member="serviceAccount:PROJECT_NUMBER@cloudbuild.gserviceaccount.com" \
--role="roles/binaryauthorization.attestorsViewer"
# In cloudbuild.yaml, use the built-in Docker build step
# Cloud Build automatically generates provenance for images pushed to
# Artifact Registry when using the docker build step from
# gcr.io/cloud-builders/docker
# cloudbuild.yaml
steps:
- name: gcr.io/cloud-builders/docker
args:
- build
- --tag
- REGION-docker.pkg.dev/PROJECT_ID/REPO/IMAGE:$COMMIT_SHA
- --tag
- REGION-docker.pkg.dev/PROJECT_ID/REPO/IMAGE:latest
- .
id: build
- name: gcr.io/cloud-builders/docker
args:
- push
- REGION-docker.pkg.dev/PROJECT_ID/REPO/IMAGE:$COMMIT_SHA
id: push
images:
- REGION-docker.pkg.dev/PROJECT_ID/REPO/IMAGE:$COMMIT_SHA
options:
# Request provenance generation (required in some configurations)
requestedVerifyOption: VERIFIED
# Restrict to managed workers for provenance guarantee
pool:
name: projects/PROJECT_ID/locations/REGION/workerPools/default
Verify provenance was generated after a build:
# List provenance for a specific image digest
gcloud artifacts docker images describe \
REGION-docker.pkg.dev/PROJECT_ID/REPO/IMAGE:$COMMIT_SHA \
--show-provenance
# Or via gcloud builds
gcloud builds describe BUILD_ID --format="json" | \
jq '.results.buildStepImages, .results.images[].digest'
Layer 2: Cosign Signing with Cloud KMS
# Create a Cloud KMS keyring and asymmetric signing key
gcloud kms keyrings create build-signing \
--location=global
gcloud kms keys create image-signer \
--keyring=build-signing \
--location=global \
--purpose=asymmetric-signing \
--default-algorithm=ec-sign-p256-sha256
# Grant Cloud Build SA permission to use the key for signing
gcloud kms keys add-iam-policy-binding image-signer \
--keyring=build-signing \
--location=global \
--member="serviceAccount:PROJECT_NUMBER@cloudbuild.gserviceaccount.com" \
--role="roles/cloudkms.cryptoKeyVersionsViewer"
gcloud kms keys add-iam-policy-binding image-signer \
--keyring=build-signing \
--location=global \
--member="serviceAccount:PROJECT_NUMBER@cloudbuild.gserviceaccount.com" \
--role="roles/cloudkms.signerVerifier"
Add a signing step to cloudbuild.yaml:
steps:
# ... build and push steps above ...
- name: gcr.io/google.com/cloudsdktool/cloud-sdk
entrypoint: bash
args:
- -c
- |
# Install cosign
curl -sSL https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64 \
-o /usr/local/bin/cosign && chmod +x /usr/local/bin/cosign
# Get image digest (sign the digest, not the tag)
IMAGE_DIGEST=$(gcloud artifacts docker images describe \
REGION-docker.pkg.dev/PROJECT_ID/REPO/IMAGE:$COMMIT_SHA \
--format="value(image_summary.digest)")
# Sign with Cloud KMS
cosign sign \
--key gcpkms://projects/PROJECT_ID/locations/global/keyRings/build-signing/cryptoKeys/image-signer \
--tlog-upload=true \
REGION-docker.pkg.dev/PROJECT_ID/REPO/IMAGE@${IMAGE_DIGEST}
echo "Signed: REGION-docker.pkg.dev/PROJECT_ID/REPO/IMAGE@${IMAGE_DIGEST}"
id: sign
waitFor: [push]
Layer 3: Binary Authorization Policy
# Create a Binary Authorization attestor
gcloud container binauthz attestors create build-verified \
--attestation-authority-note=projects/PROJECT_ID/notes/build-verified \
--attestation-authority-note-public-key-id=\
"projects/PROJECT_ID/locations/global/keyRings/build-signing/cryptoKeys/image-signer/cryptoKeyVersions/1"
# Add the KMS public key to the attestor
gcloud container binauthz attestors public-keys add \
--attestor=build-verified \
--keyversion-project=PROJECT_ID \
--keyversion-location=global \
--keyversion-keyring=build-signing \
--keyversion-key=image-signer \
--keyversion=1
Create the Binary Authorization policy:
# binauthz-policy.yaml
globalPolicyEvaluationMode: ENABLE
defaultAdmissionRule:
evaluationMode: REQUIRE_ATTESTATION
enforcementMode: ENFORCED_BLOCK_AND_AUDIT_LOG
requireAttestationsBy:
- projects/PROJECT_ID/attestors/build-verified
clusterAdmissionRules:
# Production clusters: strict enforcement
REGION.PRODUCTION_CLUSTER_NAME:
evaluationMode: REQUIRE_ATTESTATION
enforcementMode: ENFORCED_BLOCK_AND_AUDIT_LOG
requireAttestationsBy:
- projects/PROJECT_ID/attestors/build-verified
# Staging clusters: audit only (does not block, but logs violations)
REGION.STAGING_CLUSTER_NAME:
evaluationMode: REQUIRE_ATTESTATION
enforcementMode: DRYRUN_AUDIT_LOG_ONLY
requireAttestationsBy:
- projects/PROJECT_ID/attestors/build-verified
# Apply the policy
gcloud container binauthz policy import binauthz-policy.yaml
# Verify the policy is active
gcloud container binauthz policy export
Generating Attestations from Cloud Build
Binary Authorization requires a separate attestation object beyond the cosign signature. Create this in the build:
# Add to cloudbuild.yaml after signing
- name: gcr.io/google.com/cloudsdktool/cloud-sdk
entrypoint: bash
args:
- -c
- |
IMAGE_DIGEST=$(gcloud artifacts docker images describe \
REGION-docker.pkg.dev/PROJECT_ID/REPO/IMAGE:$COMMIT_SHA \
--format="value(image_summary.digest)")
# Create Binary Authorization attestation
gcloud container binauthz attestations sign-and-create \
--artifact-url="REGION-docker.pkg.dev/PROJECT_ID/REPO/IMAGE@${IMAGE_DIGEST}" \
--attestor=build-verified \
--attestor-project=PROJECT_ID \
--keyversion-project=PROJECT_ID \
--keyversion-location=global \
--keyversion-keyring=build-signing \
--keyversion-key=image-signer \
--keyversion=1
id: attest
waitFor: [sign]
Locking Down IAM
# Remove broad Artifact Registry write access from developer SAs
# Use separate service accounts for CI (write) and deployment (read)
# CI service account: can push to non-production repositories only
gcloud artifacts repositories add-iam-policy-binding REPO \
--location=REGION \
--member="serviceAccount:ci-sa@PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/artifactregistry.writer"
# Production repositories: only Cloud Build SA can write
gcloud artifacts repositories add-iam-policy-binding prod-REPO \
--location=REGION \
--member="serviceAccount:PROJECT_NUMBER@cloudbuild.gserviceaccount.com" \
--role="roles/artifactregistry.writer"
# GKE node SAs: read-only from production registry
gcloud artifacts repositories add-iam-policy-binding prod-REPO \
--location=REGION \
--member="serviceAccount:gke-node-sa@PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/artifactregistry.reader"
Monitoring and Alerting
# Cloud Logging alert for Binary Authorization denial events
gcloud logging sinks create binauthz-denials \
pubsub.googleapis.com/projects/PROJECT_ID/topics/binauthz-alerts \
--log-filter='resource.type="k8s_cluster" AND
protoPayload.methodName="io.k8s.core.v1.pods.create" AND
protoPayload.response.reason="BINAUTHZ_DENY"'
# Also alert on policy changes
gcloud logging sinks create binauthz-policy-changes \
pubsub.googleapis.com/projects/PROJECT_ID/topics/binauthz-alerts \
--log-filter='protoPayload.methodName="binaryauthorization.googleapis.com.UpdatePolicy"'
Expected Behaviour After Hardening
| Scenario | Before Hardening | After Hardening |
|---|---|---|
| Image pushed directly to prod registry by compromised SA | Deployed to GKE without check | Binary Authorization blocks pod creation; audit log records violation |
kubectl apply with un-attested image |
Pod created and scheduled | BINAUTHZ_DENY admission webhook response; pod not scheduled |
| Cloud Build completes successfully | Image available in registry | Provenance recorded; cosign signature stored; attestation created |
| KMS key rotation | Signatures from old key still valid indefinitely | Add new key version to attestor; revoke old version after rotation |
| Staging deployment with policy violation | No policy applied | Audit-log-only mode records violation; alerts fire; no block |
Verification:
# Attempt to deploy an un-attested image (should fail)
kubectl run test --image=REGION-docker.pkg.dev/PROJECT_ID/REPO/IMAGE:unattested
# Expected: Error from server: admission webhook "binauthz.googleapis.com" denied
# the request: Image REGION-docker.pkg.dev/.../IMAGE:unattested denied by
# Binary Authorization default admission rule.
# Confirm a properly attested image deploys
kubectl run prod --image=REGION-docker.pkg.dev/PROJECT_ID/REPO/IMAGE:$COMMIT_SHA
# Expected: pod/prod created
Trade-offs and Operational Considerations
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| Binary Authorization enforcement | Hard gate; un-attested images cannot run | Emergency break-glass procedure needed for incident response | Create a break-glass exemption with audit log alert; document in runbook |
| Cloud KMS signing | Hardware-backed key; audit log on every use | Cost per signing operation (~$0.0003/operation); KMS API latency | Cache attestations; minimise signing call volume in hot paths |
| Cosign Rekor transparency log | Immutable record of all image signatures | Transparency log entries are public (image digest visible) | Use private Rekor instance or --tlog-upload=false for sensitive images |
| Managed Cloud Build pool for provenance | SLSA Level 3 provenance (builder identity guaranteed) | Managed pool has higher per-minute cost vs. self-hosted runners | Apply to production builds only; use unmanaged pool for PR builds |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| KMS key version disabled | Signing fails; attestation not created; Binary Authorization blocks all new deployments | Cloud Build logs: PERMISSION_DENIED on KMS sign; GKE denials |
Re-enable key version or add new version to attestor |
| Binary Authorization policy pushed with syntax error | All pods blocked or all pods admitted (depending on default rule) | GKE pod creation failures; kubectl describe pod shows binauthz error |
gcloud container binauthz policy export and verify; roll back policy |
| Attestor note deleted | Binary Authorization cannot verify attestations | gcloud container binauthz attestors describe build-verified returns error |
Re-create attestor note; existing attestations remain valid if KMS key intact |
| Artifact Registry quota exhausted | Push fails; image not signed or attested | Cloud Build logs: quota error; Artifact Registry usage dashboard | Request quota increase; clean up old image versions |
| Cosign tlog rate-limited | Signing step fails on --tlog-upload=true |
Cloud Build step timeout; Rekor API error in logs | Set --tlog-upload=false temporarily; use private Rekor instance |