Sheet ⁨03⁩ · ⁨Case studies⁩Surveyed ⁨2026⁩

Blog post image for Hardening a CI/CD Supply Chain to SLSA Level 3 - How we made a build pipeline tamper-evident end to end: hermetic builds, KMS-signed provenance, and an admission gate that refuses anything it cannot verify.

Hardening a CI/CD Supply Chain to SLSA Level 3

Published: Updated: 11 Mins read16 Mins listen
Markdown for AI(opens in a new tab)

The question that started this was from an auditor, and it was a fair one: can you prove that the container running in production is the one your pipeline built from the commit you think it was? We had branch protection, mandatory review, image scanning, the whole checklist. None of it answered the question. We could tell a plausible story about how an image probably got there. We could not produce evidence.

This is the work that closed that gap. The target was SLSA Level 3, which in practice means three things: builds run on hardened, ephemeral infrastructure the build itself cannot tamper with; every artifact carries signed provenance describing how it was made; and something downstream actually checks that provenance before the artifact runs.

The problem

The pipeline looked fine on a diagram and was full of holes in practice.

Builds ran on a pool of long-lived EC2 runners shared between repositories. A job could read files another job left behind, and a compromised build in a low-value repo had the same registry credentials as everything else. Those credentials were static access keys stored as CI variables, rotated when someone remembered.

Dependencies resolved at build time from public registries by floating version range. Two builds of the same commit, a week apart, produced different images. That is not a hypothetical: we found a 40MB difference between two builds of the same tag while investigating something unrelated.

Deployments referenced mutable tags. A Kubernetes manifest said image: app:prod, and whatever prod pointed to at pull time was what ran. Anyone with push access could change what prod meant, on any cluster, with no review and no audit trail beyond registry logs nobody read.

The honest summary: our security model was “we trust everyone who has access, and access is broad.” The auditor’s question exposed that we had no artifact-level integrity at all. Scanning tells you an image has known CVEs. It tells you nothing about whether the image is yours.

Each row is a specific weakness and the control that replaced it. The point of the exercise was to name concrete paths, not to score a maturity model.

Constraints

A few things were fixed before we started, and they shaped every decision.

Careful here

No pipeline freeze. About 60 services deployed through this pipeline, 30 to 50 times a day between them. Any migration had to run alongside the existing flow, per service, with an easy way back.

We were on AWS and staying there, so managed services were preferred over self-hosted equivalents. That ruled out running our own Fulcio and Rekor instances for this phase, and pushed us toward KMS-backed keys.

The team was four platform engineers, part time, against a nine-week window before the audit follow-up. That budget meant we could not hand-roll verification logic per service. Whatever we built had to be central and default-on.

The last constraint was cultural and mattered most: developers would not accept a change that made deploys slower or more mysterious. If the gate rejected something, the error message had to say exactly what failed and what to do about it. A gate that produces “admission webhook denied the request” and nothing else gets disabled within a week.

Architecture

The design splits into four stages with a hard boundary between “producing an artifact” and “being allowed to run it.” The build environment produces evidence. The runtime environment demands evidence. Neither trusts the other.

Source and orchestration on top, the hermetic build inside a VPC with no internet route, signing and attestation storage below, and the verification path on the right. Every stage assumes an OIDC role scoped to that stage alone.

The build runs in CodeBuild inside a private subnet with no NAT gateway and no internet gateway. Everything it needs arrives through VPC endpoints: CodeArtifact for dependencies, ECR for base images, S3 for caches, KMS for signing. A build that tries to curl an arbitrary host fails, which is exactly the behaviour we want. This is the “hermetic” part of Level 3, and it is the control that stops a malicious dependency from phoning home during a build.

Signing uses an asymmetric KMS key. The private key never exists outside KMS, so a fully compromised build host can ask KMS to sign something but cannot steal the key and sign things later, or elsewhere. The key policy allows kms:Sign only from the build role, and only when the request comes through the VPC endpoint.

Provenance goes in two places. The attestation is attached to the image in ECR as an OCI referrer, which is what the admission webhook reads. A copy also lands in an S3 bucket with Object Lock in compliance mode and a one-year retention, which is what we hand an auditor. The two are the same document; the duplication is deliberate, because ECR lifecycle policies will eventually expire images and we did not want evidence expiring with them.

Note

Deploying by digest is the control that makes all of this worth anything. If manifests reference mutable tags, an attacker does not need to defeat your signature checks. They repoint the tag and wait for the next pod restart. We rewrote every manifest to image: repo@sha256:... before turning on verification.

Verification happens at admission. The webhook resolves the image digest, fetches the attestation, verifies the KMS signature, then checks the claims inside it against policy: the builder ID must be our CodeBuild project ARN, the source repository must be on an allowlist, and the commit must exist on a protected branch. A failure denies the pod and writes a Security Hub finding.

The full round trip from a pushed tag to an admitted pod. Note that the signature request goes to KMS and only the signature comes back, so the private key never reaches the builder.

Implementation

We rolled this out in five steps, and deliberately did the boring ones first. Steps one to three change nothing observable, which is what made them safe to do quickly.

  1. Move dependency resolution behind CodeArtifact. Point every package manager at an upstream-backed CodeArtifact repository and commit lockfiles everywhere. This alone made builds reproducible enough that the later provenance meant something. It also surfaced 14 packages nobody could explain, three of which were unused.

  2. Rebuild the build environment as ephemeral and networkless. New CodeBuild projects in a private subnet, VPC endpoints for the four services builds actually need, and a security group with no egress rules. Run both pipelines in parallel per service and diff the outputs until they match.

  3. Generate provenance without enforcing it. Emit an in-toto attestation from every build, sign it with KMS, attach it to the image, and copy it to S3. Nothing verifies yet. This is the observation phase, and it is where we found that 9 of 60 services were building from a directory that was not the repository root, which would have failed verification later.

  4. Deploy the webhook in dry-run mode. Verify every pod, deny nothing, log the verdict. Two weeks of this produced a list of every image that would have been blocked. Most were base images from before step three; two were a genuine surprise, built on a developer laptop and pushed by hand.

  5. Enforce, namespace by namespace. Turn the webhook from audit to enforce, starting with the namespace the platform team owns, then non-production, then production in order of ascending traffic. Keep a documented per-namespace escape hatch requiring a second approver.

The build step itself is unremarkable, which is the point. This is the signing portion of the buildspec:

# buildspec.yml (post_build stage)
post_build:
commands:
# Push first, then sign what the registry actually stored. Signing a
# locally-computed digest and hoping the registry agrees is how you get
# signatures that verify against nothing.
- docker push "$REPO:$COMMIT_SHA"
- |
DIGEST=$(aws ecr describe-images \
--repository-name "$ECR_REPO" \
--image-ids imageTag="$COMMIT_SHA" \
--query 'imageDetails[0].imageDigest' --output text)
- echo "signing $REPO@$DIGEST"
- cosign sign --key "awskms:///$KMS_KEY_ID" --yes "$REPO@$DIGEST"
- |
cosign attest --key "awskms:///$KMS_KEY_ID" --yes \
--type slsaprovenance \
--predicate provenance.json \
"$REPO@$DIGEST"
- |
aws s3 cp provenance.json \
"s3://$EVIDENCE_BUCKET/${ECR_REPO}/${DIGEST#sha256:}.json"

The provenance document is generated from the build environment rather than hand-written, because anything hand-written drifts:

#!/usr/bin/env bash
# generate-provenance.sh - runs inside CodeBuild, before signing.
set -euo pipefail
cat > provenance.json <<JSON
{
"buildDefinition": {
"buildType": "https://mkabumattar.com/slsa/codebuild/v1",
"externalParameters": {
"repository": "${CODEBUILD_SOURCE_REPO_URL}",
"ref": "${GIT_REF}"
},
"internalParameters": {
"buildProject": "${CODEBUILD_BUILD_ARN%:*}",
"image": "${CODEBUILD_BUILD_IMAGE}"
},
"resolvedDependencies": $(jq -c '[.dependencies[]
| {uri: .name, digest: {sha512: .integrity}}]' lockfile-normalised.json)
},
"runDetails": {
"builder": { "id": "${CODEBUILD_BUILD_ARN%:*}" },
"metadata": {
"invocationId": "${CODEBUILD_BUILD_ID}",
"startedOn": "${BUILD_START_TIME}"
}
}
}
JSON
# Fail the build rather than emit provenance that claims nothing.
jq -e '.buildDefinition.resolvedDependencies | length > 0' provenance.json >/dev/null

The verification policy is the piece worth reviewing carefully, because a policy that only checks “is there a signature” is theatre. A signature proves someone with the key signed it. The claims are what tell you who built what from where:

# policy.yaml - evaluated by the admission webhook
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
name: require-slsa-provenance
spec:
images:
- glob: '123456789012.dkr.ecr.eu-west-1.amazonaws.com/**'
authorities:
- name: kms-release-key
key:
kms: awskms:///arn:aws:kms:eu-west-1:123456789012:key/PLACEHOLDER
attestations:
- name: must-have-slsa-provenance
predicateType: slsaprovenance
policy:
type: cue
data: |
// Builder must be our CodeBuild project, not any signer.
predicate: builder: id: "arn:aws:codebuild:eu-west-1:123456789012:project/release-builder"
// Source must be a repo we own.
predicate: invocation: configSource: uri: =~"^git\\+https://git-codecommit\\.eu-west-1\\.amazonaws\\.com/v1/repos/(platform|payments|web)-"

Tip

Write the denial message before you write the policy. Ours resolves the failed CUE constraint into a sentence like image not admitted: provenance builder id is "…/laptop-build", expected "…/release-builder" (see go/slsa-denied). Support load on the rollout was close to zero, and that message is the only reason.

The IAM boundary deserves a note. Each pipeline stage assumes a distinct role through OIDC, and the signing role is the only one with kms:Sign. The build role cannot sign, and the signing stage cannot modify source. Splitting these means compromising the build does not get you a signing oracle for arbitrary content:

{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "kms:Sign",
"Resource": "arn:aws:kms:eu-west-1:123456789012:key/PLACEHOLDER",
"Condition": {
"StringEquals": {
"aws:SourceVpce": "vpce-PLACEHOLDER",
"kms:MessageType": "DIGEST"
}
}
}
]
}
  • Directorypipeline/
    • buildspec.yml
    • generate-provenance.sh
    • Directorypolicy/
      • policy.yaml
      • denial-messages.yaml
    • Directoryterraform/
      • kms.tf
      • iam.tf
      • codebuild.tf
      • vpc-endpoints.tf
  • Directorycharts/
    • Directoryadmission-webhook/
      • values.yaml
      • values-enforce.yaml

Results

These numbers come from our own pipeline telemetry over the nine weeks plus the six weeks after enforcement, and I have marked what is measured against what is estimated.

MeasureBeforeAfterSource
Images deployable without provenanceall0measured, admission logs
Services deploying by mutable tag60 of 600 of 60measured, manifest audit
Static registry credentials in CI110measured, secret scan
Median build duration4m 10s4m 48smeasured, CodeBuild metrics
Rebuild of same commit produces same digestno52 of 60 servicesmeasured
Time to answer “where did this image come from”hoursone queryestimated

The build slowdown is real and worth naming: about 38 seconds median, from signing, attestation, and pulling dependencies through an endpoint instead of a warm local cache. Nobody complained, but I would not have gotten away with 3 minutes.

Eight services still do not reproduce bit-for-bit. All eight embed a build timestamp somewhere in their output. That is a fixable problem and it is on the backlog, not a blocker, because provenance still tells you which build produced which digest even when a rebuild would differ.

The admission gate denied 23 pods in the first six weeks. Twenty were stale base images during the tail of the rollout. Two were hand-built images, which is exactly the case this was designed to catch. One was a genuine incident: an image built from a fork by a misconfigured pipeline in a sandbox account. Nothing malicious, but under the old setup it would have run in production and nobody would have known.

The part I did not expect was how much easier incident response got. “Which commit is running” used to be a twenty minute archaeology exercise. Now it is one command.

Lessons

Signing without verification is just metadata. We spent the first three weeks on signing infrastructure and it bought us nothing until the gate went in. If you are short on time, design the gate first and work backwards; it forces you to be honest about which claims you can actually check.

Dry-run mode is not optional. Two weeks of logging verdicts and denying nothing found 11 problems that would each have been a production incident on enforcement day. The temptation to skip it because “we already know what is deployed” was strong and would have been wrong.

Hermetic builds break things you did not know were network-dependent. A test suite that quietly hit a public API. A build script that fetched a shell script from a gist. A base image that ran apt-get update at build time. Each was a small fix, and each was also a real supply chain hole we would never have found by reading code.

The escape hatch keeps the control alive. We built a documented, audited, two-approver bypass and expected it to be abused. It was used twice in six weeks, both times legitimately, during an incident. Teams tolerate a strict gate when there is a visible, honest way through it. Without one, someone eventually disables the webhook at 3am and nobody turns it back on.

Do not chase the level number. SLSA levels are a useful shared vocabulary and a terrible goal. The valuable artifacts here were the row-by-row threat list and the denial messages, neither of which appears in the spec. We hit Level 3 for the container build path, and the Terraform that provisions the cluster is still Level 1. Saying that plainly is more useful than a badge.

Frequently Asked Questions

Keyless signing is a better long-term answer, because a short-lived certificate tied to a workload identity beats a long-lived key you have to protect. We chose KMS for this phase because it was already in our account, the key policy could be expressed in the IAM we already reviewed, and we did not have the appetite to run a transparency log before the audit. The cosign commands are nearly identical, so switching later is a change to one flag and the policy’s authority block.

It can, and the default configuration is dangerous. A webhook with failurePolicy: Fail that becomes unavailable blocks every pod creation in the cluster, including the webhook’s own replacement pods. We run three replicas across availability zones with a pod disruption budget, exclude the webhook’s own namespace and kube-system from the policy, and keep the image cached on every node. We also rehearsed the recovery: a documented, break-glass procedure that flips to Ignore, which we have tested twice in game days and used zero times in anger.

For us, about 38 seconds at the median, and that splits roughly into 6 seconds for the two KMS signing calls, 9 seconds for pushing and attaching the attestation, and the rest from resolving dependencies through a VPC endpoint rather than a local cache. The KMS portion is nearly constant regardless of image size, because you sign a digest rather than the image itself. If your builds get much slower than that, the cause is usually the network topology change, not the cryptography.

They fail verification, because they have no attestation. We handled this by rebuilding every actively deployed service through the new pipeline during the dry-run phase, which took a coordinated afternoon. For images that are deployed rarely, the dry-run logs told us exactly which ones would break, so we could rebuild them ahead of enforcement rather than discovering it during an urgent deploy. There is no honest shortcut here: an unsigned legacy image either gets rebuilt or gets an audited exception.

Yes, and the build side is identical. What changes is where verification happens. On ECS you can verify in a deployment pipeline step before updating the task definition, or use a Lambda hooked to the deployment event. On EC2 with an AMI pipeline you verify before the AMI is marked as shared. The pattern is the same: find the last point where you control whether the artifact runs, and verify there. The Kubernetes admission webhook is convenient because it is a natural chokepoint, not because it is required.

Not with the same policy, because we cannot demand our builder ID from an upstream image. We run a second policy for a small allowlist of external registries that requires a valid upstream signature where the publisher provides one, and pins by digest in all cases. Everything else is mirrored into our own ECR through a review step, and once mirrored it gets our attestation describing the mirroring, which is honest about what it proves: we vouch that this is the bytes we reviewed, not that we know how upstream built them.

The escape hatch is a label on the namespace that the policy excludes, applied through a short-lived pull request that requires a second approver and expires automatically after four hours via a controller that strips the label. Every use writes to CloudWatch and raises a Security Hub finding, so it is visible rather than quiet. Making it slightly awkward and completely visible has worked better than making it impossible, which just moves the bypass somewhere we cannot see.

References

Was this useful?

You might also enjoy

More posts on similar topics

QuenchWorks: Building a 0-CVE Container Image and Helm Chart Catalog

QuenchWorks: Building a 0-CVE Container Image and Helm Chart Catalog

When Bitnami moved its long-trusted catalog behind a paid tier, thousands of teams woke up to a supply-chain problem they didn't choose. The free images they had pinned in production would stop gettin

Cutting a SaaS AWS Bill 41% Without Slowing Delivery

Cutting a SaaS AWS Bill 41% Without Slowing Delivery

A growing SaaS ran on EKS with a full GitOps pipeline, and it was over its AWS budget nearly every month. The reflex from leadership was the usual one: freeze features until the bill comes down. That

Migrating a Monolith to Kubernetes Without a Big-Bang Cutover

Migrating a Monolith to Kubernetes Without a Big-Bang Cutover

Almost every failed "let's move off the monolith" project shares one detail: the plan was a big-bang cutover. Rewrite in parallel, pick a weekend, flip the switch, and pray. This is the opposite of th

Building an Internal Developer Platform on Backstage and GitOps

Building an Internal Developer Platform on Backstage and GitOps

Product teams were spending more time waiting on the platform team than building features. Spinning up a new service meant opening a ticket and waiting for someone to provision a repo, wire up CI, wri

Taming a 3am Pager: SLOs and Error Budgets That Stuck

Taming a 3am Pager: SLOs and Error Budgets That Stuck

A platform team was losing people to burnout, and the cause was the pager. On-call meant a phone that went off day and night with alerts about CPU, memory, and pod restarts, the overwhelming majority

Zero-Downtime PostgreSQL Major-Version Upgrade at Scale

Zero-Downtime PostgreSQL Major-Version Upgrade at Scale

A multi-terabyte PostgreSQL 12 database was reaching end of life, and the business ran around the clock, so the usual answer of "schedule a maintenance window" was off the table. We upgraded it to Pos

6 related posts