July 1, 2026

Software Supply Chain Security on AWS: Keyless Signing, SBOMs, and SLSA Provenance

A GitHub Actions pipeline that builds a container image, gates it on fixable CVEs, signs it with Cosign keyless, attaches SLSA provenance, and refuses to call it deployable unless all of that verifies against the exact repo that built it.

Software Supply Chain Security on AWS: Keyless Signing, SBOMs, and SLSA Provenance

An image scanner answers one question: does it contain known vulnerabilities right now. It does not answer the question that matters in a supply chain attack: where did this image come from. An attacker who compromises your CI can push a malicious image under a legitimate tag and still get a clean scan. The scanner was never looking at provenance.

That gap is not hypothetical. The CircleCI breach in 2023, the Codecov bash-uploader compromise in 2021, the SolarWinds build-system attack: none of them shipped a CVE. All of them got malicious code into a trusted build or distribution path. A pipeline that only scans for vulnerabilities is blind to every one of them.

This project closes that gap for a container image. GitHub Actions builds it and pushes it to ECR. Amazon Inspector scans it. Syft produces an SBOM. Cosign signs it with no private key anywhere. A SLSA provenance attestation records what built it. Before the image counts as deployable, a verification step checks the signature, the signing identity, and the provenance together. If any of that fails, the pipeline fails.

The Trust Model

The shift this project makes is easy to state: an image is trusted not because it’s in your registry, but because it carries a signature proving which repository and workflow produced it. Anyone can verify that signature without a shared secret.

Keyless signing gets you there with no key to manage or leak. When the workflow runs, it holds a short-lived OIDC identity token encoding exactly what it is: repo:ToluGIT/aws-supply-chain-security:ref:refs/heads/main. Cosign presents that token to Sigstore’s Fulcio, which issues a ten-minute X.509 certificate bound to that identity. Cosign signs the image digest with the ephemeral key, records the signature in Rekor (Sigstore’s public transparency log), and the certificate expires. There is no long-lived private key, full stop.

Verification then checks three things that all have to hold:

  1. The signature is valid and came from a Fulcio-issued certificate, not a self-signed one.
  2. The certificate identity matches the repository and workflow you expect, not some other project that also signs keyless.
  3. A Rekor entry proves the signature existed at signing time.

Most setups get the second check wrong by omitting it. Keyless signing alone only proves “some GitHub workflow signed this.” Pinning the expected identity turns that into “the workflow in my repo, on my branch, signed this.” I prove that failure mode in the adversarial tests later.

Why Cosign keyless, not AWS Signer

AWS has its own signing service, so the obvious question is why reach for an OSS tool.

Cosign keylessAWS Signer (Notation)
Key managementNone, ephemeral Fulcio certsManaged signing profile and key
Signing identityThe workflow’s OIDC identity, pinned at verifyAn AWS signing profile
Transparency logRekor, public, anyone can auditNone public
Verifiable byAnyone, no AWS credentialsRequires AWS-side trust setup
EcosystemThe de facto standard for OCI signing; Kubernetes admission controllers speak itAWS-native, narrower support

I picked Cosign keyless. The signature binds to the exact repo, workflow, and ref, not to a shared AWS profile, and the Rekor log makes it independently auditable. AWS Signer is fine if you want managed keys and Notation in an all-AWS shop, but for a container image the OSS keyless pattern is stronger on identity and more portable.

The AWS side of this is deliberately thin: one ECR repository, one S3 bucket for Inspector’s SBOM exports, one IAM role for the keyless identity. Everything interesting happens in the pipeline.

The AWS Bootstrap

Two ECR settings, both set at repository creation, carry most of the weight. Tag immutability stops an attacker with push access from overwriting a known-good tag with a malicious image of the same name. Scan-on-push hands every new image to Inspector automatically.

ECR repository scs-prod-ecr-app showing immutable tags and continuous scan-on-push

The IAM role is where the keyless-to-AWS half lives, and its whole security comes down to one line in the trust policy: the sub condition.

"Condition": {
  "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
  "StringLike": { "token.actions.githubusercontent.com:sub": "repo:ToluGIT/aws-supply-chain-security:*" }
}

The GitHub OIDC token’s sub claim encodes the repository and ref that requested it, in the form repo:OWNER/NAME:ref:refs/heads/BRANCH. The StringLike on repo:ToluGIT/aws-supply-chain-security:* means only workflows in that repository can assume this role. Any other repo, in the same org or anywhere else, presents a sub that doesn’t match, and AssumeRoleWithWebIdentity gets denied. That’s the control that stops the obvious lateral move.

Getting this condition wrong, say by scoping it to repo:ToluGIT/* or omitting it, would let any repo the attacker controls assume the role. The trailing :* is deliberately permissive on ref: any branch or tag in this repo can assume the role. Tightening it to :ref:refs/heads/main would restrict it to main, the stricter production choice.

IAM role trust policy federating GitHub OIDC, scoped with a sub condition to repo:ToluGIT/aws-supply-chain-security

There are no AWS access keys stored in GitHub anywhere. The workflow requests an OIDC token, calls AssumeRoleWithWebIdentity, and gets short-lived STS credentials for the run: the single most effective control against the most common CI breach, a long-lived key committed to a repo or left in a secrets store. There is nothing durable to leak.

SBOM exports go to a versioned, KMS-encrypted S3 bucket. Inspector was already enabled for ECR scanning from an earlier project, so scan-on-push worked the moment the first image landed.

The Build, Scan, and Gate Pipeline

The workflow authenticates to AWS keyless, builds the image, tags it by commit (git-<sha>), pushes it, waits for Inspector, then gates on CVEs before anything is signed. The order matters: a vulnerable image must never reach the signing step.

Two things about the CVE gate turned out to be less obvious than expected.

First, ECR enhanced scanning never reports a status of COMPLETE. Basic scanning has a terminal COMPLETE status; Inspector (enhanced scanning) reports ACTIVE with the description “Continuous scan is selected” and keeps monitoring. My first gate polled for COMPLETE and would have waited forever. The fix: poll for ACTIVE on the image digest, then read findings.

Second, and more interesting: the “clean” image is not CVE-free, and gating on all CRITICALs would block every build. The python:3.12-slim base ships a perl package with a CRITICAL CVE that has no upstream fix, and you can’t patch what has no patch. Gate on any CRITICAL and it fails on that base-image CVE forever, no matter how clean your own dependencies are. So the gate fires only on findings that have a fix available:

count_fixable() {
  aws inspector2 list-findings \
    --filter-criteria "{\"ecrImageHash\":[{\"comparison\":\"EQUALS\",\"value\":\"$DIGEST\"}],\"severity\":[{\"comparison\":\"EQUALS\",\"value\":\"$1\"}],\"fixAvailable\":[{\"comparison\":\"EQUALS\",\"value\":\"YES\"}]}" \
    --region "$AWS_REGION" --query "length(findings)" --output text
}
FIX_CRITICAL=$(count_fixable CRITICAL)
FIX_HIGH=$(count_fixable HIGH)

Gating on fixable findings is the defensible threshold: block what you can act on, ignore what you can’t. The clean image has zero fixable CRITICAL or HIGH findings and passes; its only CRITICAL is the unpatchable base perl one.

After the gate, Syft generates a CycloneDX SBOM and uploads it as a build artifact.

GitHub Actions run showing a successful build with the CycloneDX SBOM produced as a hashed artifact

Signing, Provenance, and the Verify Gate

Once the image clears the CVE gate, the workflow installs Cosign and signs it by digest, never a tag, on purpose: tags move, digests are content addresses. A signature bound to a tag proves nothing once the tag is repointed.

- name: Cosign keyless sign (by digest)
  run: cosign sign --yes "${ECR_URI}@${DIGEST}"

Keyless is the default in Cosign 2.x: the step generates ephemeral keys, gets a Fulcio certificate for the workflow’s OIDC identity, signs, and writes a transparency log entry.

Cosign keyless signing step: ephemeral keys, Fulcio certificate, Rekor tlog entry created

A SLSA provenance attestation follows, describing the source repo, commit, workflow, and builder. To be precise about the level: this is SLSA Build L2, since the provenance is generated in the same job that builds the image. Genuine L3 needs an isolated, non-forgeable builder (the slsa-github-generator reusable workflow runs provenance generation in a separate job the build can’t tamper with). L2 is the honest claim for a single-job cosign attest predicate; overclaiming L3 would be exactly the unverifiable provenance this project argues against.

The verification gate runs next, in the same pipeline, and fails the run if anything doesn’t check out. This is the payoff step: it confirms the signature is valid, the certificate subject is the exact workflow file in this repo on this ref, the issuer is GitHub’s OIDC, and the SLSA attestation verifies against that same identity. The final log line is the whole point: signed, attested, verified, therefore deployable.

Verification gate: certificate subject is the exact workflow/repo/ref, issuer is GitHub OIDC, provenance verified, image declared deployable

The signature isn’t only in ECR. It’s in Rekor too, Sigstore’s public transparency log, queryable by anyone with the log index. You don’t have to trust my pipeline’s word that it signed the image; you can look it up.

Rekor transparency log entry for the signature, queried by log index

Proving It: The Adversarial Tests

Having built the controls, the next step is proving they work by attacking them. A pipeline that only ever shows green proves nothing; anyone can make a demo pass. What matters is running the attacks these controls are supposed to stop and watching each one fail closed.

A vulnerable image is blocked before it can be signed

The pipeline has a second app variant with dependencies pinned to old versions carrying recent CVEs: PyYAML 5.3.1 (CVE-2020-14343, arbitrary code execution), certifi 2022.12.7, Werkzeug 2.0.3, and others. Inspector flags them, and unlike the base-image perl CVE, these have fixes available.

Inspector findings for the vulnerable image: fixable and exploitable CRITICAL CVEs including PyYAML CVE-2020-14343

Running the pipeline against this variant, the CVE gate counts three fixable CRITICAL and seventeen fixable HIGH findings and exits non-zero. Everything after it (the SBOM, the Cosign signing, the provenance attestation, the verify gate) gets skipped. The vulnerable image sits in ECR unsigned, so no downstream verification can ever pass it.

Getting this gate to work took three tries, and the failures teach more than the success. Version one used a shell eval to build the finding counts and crashed with exit 127; blocked, but by a shell error, not a gate decision.

Version two was worse: it failed open. inspector2 list-findings paginates, and length(findings) with text output prints one number per page joined by a newline, so the count came back as "3\n0". The comparison [ "3\n0" -gt 0 ] threw “integer expression expected,” the if short-circuited to false, and the gate printed “passed” while signing a vulnerable image carrying three fixable CRITICAL CVEs. A gate that fails open is worse than no gate: it produces a signed, apparently-verified artifact that’s actually dangerous.

Only re-running the adversarial test caught it, the log saying “passed” while the counts right above it said three critical. The fix: sum the paginated counts into a single integer with awk before comparing. The lesson is that a security control isn’t proven by writing it; it’s proven by watching it reject the thing it’s supposed to reject.

CVE gate step failing on fixable CRITICAL findings, with all signing and attestation steps skipped

A tampered image has no valid signature

The realistic attack: an adversary with push access takes the legitimate image, adds a malicious layer, and pushes it under a plausible tag like v1.0-hotfix. The layer is an IMDS credential-exfiltration stub, the same primitive behind the TeamTNT cryptojacking campaigns and the Capital One breach: read the instance metadata credentials, beacon them out. The exfil endpoint is a placeholder, so it demonstrates the technique without stealing anything.

Because Cosign signs the digest, the tampered image has a different digest, and there’s no signature for it. cosign verify returns “no signatures found.” The malicious image sits in the registry under a legitimate-looking tag, but it can’t be verified, so a deploy gate running cosign verify blocks it.

cosign verify returning "no signatures found" on the tampered tag, then passing on the genuine signed digest

To be precise about what this proves: Cosign didn’t detect modified bytes inside a signed image (it can’t, and doesn’t try). The signature is bound to the original digest, so any change produces a new digest with no signature at all. The tampered image is rejected not because it was caught being altered, but because it was never signed.

A valid signature from the wrong identity is rejected

This is the sharpest result. Take the genuinely signed, legitimate image and verify it while pinning the wrong expected repository. The signature is valid, the image is authentic, and verification still fails, because the certificate identity doesn’t match:

Error: no matching signatures: none of the expected identities matched
what was in the certificate, got subjects
[https://github.com/ToluGIT/aws-supply-chain-security/.github/workflows/supply-chain.yml@refs/heads/main]

cosign verify rejecting a valid signature when the expected identity is wrong, then passing with the correct identity

Same image, same signature, verified twice, differing only in the expected identity: one rejected, one passed. That’s the point from the trust model. Keyless signing without an identity pin reduces to “someone signed this,” which is nearly worthless. The --certificate-identity flags are the difference between that and “my workflow signed this.”

Where This Has Limits

The provenance is SLSA Build L2, not L3, as discussed. For a threat model that includes a compromised build job forging its own provenance, you need the isolated builder.

The CVE gate depends on Inspector having a fix-availability signal, which it doesn’t always have right away for the newest CVEs. A brand-new fixable CRITICAL with fixAvailable not yet populated would slip the gate until Inspector catches up. Gating additionally on an Inspector score threshold would tighten this.

The most important limitation is where verification runs, and it deserves more than a footnote. This pipeline verifies the image inside itself, at build time, which proves the artifact was signed and attested the moment it was produced. It does nothing to stop someone deploying a different, unsigned image later.

The signature only becomes a deploy-time control once something refuses to run an image that fails cosign verify at the point of deployment. On EKS that’s an admission controller: Sigstore’s Policy Controller or a Kyverno verifyImages policy, either of which rejects a pod whose image isn’t signed by the expected identity, using the same --certificate-identity and issuer checks the pipeline uses. On ECS or Lambda, the equivalent is a deploy-time gate in the release pipeline that runs cosign verify before promotion. Without one of these, the signature is a claim nobody checks, and it stops mattering the moment an attacker deploys around it. That admission-time enforcement is the natural next project, and it’s where the signing work here pays off.

Back to blog