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.
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:
- The signature is valid and came from a Fulcio-issued certificate, not a self-signed one.
- The certificate identity matches the repository and workflow you expect, not some other project that also signs keyless.
- 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 keyless | AWS Signer (Notation) | |
|---|---|---|
| Key management | None, ephemeral Fulcio certs | Managed signing profile and key |
| Signing identity | The workflow’s OIDC identity, pinned at verify | An AWS signing profile |
| Transparency log | Rekor, public, anyone can audit | None public |
| Verifiable by | Anyone, no AWS credentials | Requires AWS-side trust setup |
| Ecosystem | The de facto standard for OCI signing; Kubernetes admission controllers speak it | AWS-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.

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.

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.

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.

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.

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.

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.

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.

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.

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]

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.