Plate 13
Cosign + SBOM in CI: Sign and Attest a Container Image in One Workflow
One CI shape: build an image by digest, Syft SBOM, Cosign sign + SBOM attest, verify success, and prove wrong-key/unsigned failure — the operational follow-on to after-xz.
Aditya Challa8 min read
On this page
- Intro — what this post promises
- Goals for one workflow (not five half-finished jobs)
- Mental model — keyless Cosign in GitHub Actions
- Generate an SBOM (Syft — measured)
- End-to-end workflow shape (CI target + what the lab proved)
- Failure demo — the step most tutorials skip
- Deploy gate (minimum)
- What this workflow still does _not_ claim
- Worked mini-lab (filled)
- FAQ
- More reading
- Stay in touch
Intro — what this post promises
The after-xz checklist tells you what to require. This post shows one concrete shape that does it for a container image:
- Goals: sign, attest, verify (fail closed).
- Generate an SBOM next to the image.
- Cosign keyless via GitHub OIDC (
id-token: write) — or a local key when OIDC is blocked, labeled honestly. - Attach an SBOM attestation (Cosign attest in this lab).
- Demonstrate verify failure on purpose.
- Honest limits — what Cosign still does not catch (xz-class lessons).
Related reading lives on the xz-utils backdoor technical deep dive (narrative context) and Not Git Yard / fuzz-based defense (detection complementary to signing). The after-xz solo-team checklist is the “why” companion.
Related links:
- After xz: supply-chain checklist for solo and small teams
- xz-utils backdoor technical deep dive
- Not Git Yard / fuzz-based defense
Lab honesty (29 Sep 2026 IST): Docker and GHCR were unavailable on the lab box. We ran a throwaway local OCI image on 127.0.0.1:5000 (distribution/registry 3.0.0 + crane 0.20.2): digest sha256:de23658418d8f8b507e13cb2f36e45d6fc394b2015b5e2671c74e3a656f36f91. Syft 1.18.1 produced SPDX/CycloneDX. Cosign v2.4.1 keyless hit Sigstore device-flow OIDC and timed out (no browser / no Actions id-token). Local key sign+verify succeeded (Rekor 3003586630); SBOM attest Rekor 3003588212; wrong-key exit 12; unsigned exit 10. Attestation path for v1: Cosign attest (not actions/attest).
A practical Cosign + SBOM gate is: build by digest → Syft SBOM → Cosign sign → Cosign attest SBOM → verify expected key/identity → deliberately fail wrong-key/unsigned. Signatures without a verify gate are theater.
Goals for one workflow (not five half-finished jobs)
| Goal | Pass criteria | Lab 29 Sep 2026 IST |
|---|---|---|
| Sign | Image digest has a Cosign signature | Local key → Rekor 3003586630 |
| SBOM | SPDX or CycloneDX from the image | Syft 1.18.1 → stdlib @ go1.24.4 + module |
| Attest | SBOM attestation attached | Cosign attest SPDX → Rekor 3003588212 |
| Verify | Expected key/identity succeeds | cosign verify --key lab08.pub → exit 0 |
| Fail demo | Wrong key / unsigned / wrong-identity fails | exits 12 / 10 / 12 |
If you only push latest unsigned, stop reading tutorials and start here.
Mental model — keyless Cosign in GitHub Actions
Sigstore’s Cosign overview explains identity-based (“keyless”) signing: Fulcio issues a short-lived certificate binding an ephemeral key to an OIDC identity; Rekor records the signing event.
Related links:
In GitHub Actions you need (OIDC in Fulcio; CI quickstart):
Related links:
Verify with identity + issuer, not “a signature exists”:
certificate-oidc-issuer:https://token.actions.githubusercontent.comcertificate-identity:https://github.com/USER/REPO/.github/workflows/WORKFLOW.yml@refs/heads/BRANCH
Lab note: Keyless did not complete here (device-flow OIDC, exit 124 after 25 s). The identity URI above is the CI target, not a measured Fulcio subject from this box. What we measured was local-key verify against lab08.pub.
Generate an SBOM (Syft — measured)
Inventory ≠ malware scan. Still non-optional for release hygiene.
Pinned in lab: Syft 1.18.1.
| Package (SPDX) | Version |
|---|---|
shoppercove.local/lab08-throwaway | (devel) |
stdlib | go1.24.4 |
| image root | digest sha256:de236584… |
Official tooling: anchore/syft. We attached the SBOM as a Cosign attestation and kept the JSON as a lab artifact.
Related links:
End-to-end workflow shape (CI target + what the lab proved)
Lab proved the sign / SBOM / attest / verify / fail loop on a local OCI digest with a local Cosign key. The YAML below is the GitHub Actions keyless target for when you have id-token: write + a registry. Pin action SHAs in production; replace ORG/APP.
Attestation path for this article’s lab: Cosign attest with --type spdxjson. We did not run actions/attest (no Actions runner). Prefer one verify story you will actually run. GitHub documents the alternate path via actions/attest.
Related links:
Local-key equivalent we ran (when keyless is blocked):
Label local keys honestly: they prove you hold the private key, not that GitHub OIDC minted a Fulcio cert for a workflow identity.
Failure demo — the step most tutorials skip
| Case | Expected exit | Lab result |
|---|---|---|
| Correct local key | 0 | 0 |
| Wrong key | ≠0 | 12 (PEM mismatch / no matching signatures) |
| Unsigned digest | ≠0 | 10 (no signatures found) |
| Wrong certificate-identity (on key-signed image) | ≠0 | 12 (nil certificate provided) |
Wire the wrong-key / wrong-identity command so the job fails closed — signatures without a verify gate are theater.
Deploy gate (minimum)
Signing in CI is useless if CD pulls by mutable tag without verify.
Minimum for a solo team:
- Deploy references digest.
cosign verifyin CD or admission policy.- Reject on failure.
We simulated the reject with an unsigned sibling digest (sha256:3e64b43d…) → verify exit 10. Kubernetes admission (Kyverno/Gatekeeper/Ratify/etc.) is optional for v1; a shell verify in CD counts.
What this workflow still does not claim
| Claim to avoid | Why |
|---|---|
| “Signed = safe code” | Identity of builder ≠ correctness of logic |
| “SBOM finds backdoors” | Inventory only |
| “We would have stopped xz automatically” | Signing your own image ≠ detecting malicious upstream build scripts; keep tarball diffs + fuzz/Lily for that class |
“Pinning @v4 actions is enough” | Prefer SHA pins for actions in production |
| “This lab ran keyless on GHCR” | It did not — local OCI + local key; keyless blocked on device-flow OIDC |
Cross-link the after-xz checklist for tarball≠git; cross-link Lily / fuzz-based defense for behavioral detection.
Related links:
Worked mini-lab (filled)
| Step | Artifact / signal | Lab status |
|---|---|---|
| Throwaway image by digest | 127.0.0.1:5000/lab08/throwaway@sha256:de236584… | Done (local OCI; not GHCR) |
| Syft SBOM | lab08-throwaway.spdx.json (stdlib go1.24.4) | Done |
| Cosign sign | Rekor 3003586630 | Done (local key) |
| Verify success | exit 0 | Done |
| Verify fail | wrong-key 12, unsigned 10 | Done |
| Attestation | Cosign attest SPDX, Rekor 3003588212, verify-attestation 0 | Done |
| Keyless OIDC | device-flow timeout exit 124 | Blocked (honest) |
Time budget this afternoon: tools + image + SBOM + sign/verify/attest loop on the order of minutes once binaries were present; first-time GHCR + GHA keyless wiring was not timed.
FAQ
Cosign vs actions/attest — pick one?
Cosign is portable across registries and non-GitHub verifiers. actions/attest integrates tightly with GitHub’s attestation store + gh attestation verify. This lab used Cosign attest only. Start with one verify story you will actually run.
Do private repos use public Rekor?
GitHub’s attestation docs note public-good Sigstore for public repos and GitHub’s private Sigstore instance for private/internal. Confirm for your org settings. Our local-key signatures still wrote public Rekor entries (indexes 3003586630 / 3003588212).
Why verify SBOM with an explicit type?
cosign verify-attestation --type spdxjson (or gh attestation verify with --predicate-type) must not accidentally treat an SBOM attestation as build provenance.
Is keyless safe without long-lived keys?
It removes laptop signing keys; you must still protect workflow permissions, environment approvals, and who can change the signing workflow. When keyless is blocked, a local key is acceptable for a lab only if you label it as not an OIDC identity proof.
Can I skip SBOM if I sign?
You can ship unsigned inventory gaps. Signing proves who built the bits; SBOM tells you what is inside. Different questions.
How does this relate to Lily?
Cosign answers “who built this digest?” Lily-style fuzzing answers “does this change introduce triggerable malicious behavior?” Use both when feasible. See Not Git Yard / fuzz-based defense.
Related links:
More reading
- After xz: a practical supply-chain checklist for solo and small teams
- xz-utils backdoor technical deep dive
- Not Git Yard / fuzz-based defense
- About ShopperCove
- Cosign signing overview
- Cosign CI quickstart
- OIDC in Fulcio
- GitHub artifact attestations
- Syft
- SLSA Build levels
Stay in touch
- Subscribe via RSS: https://www.shoppercove.com/feed.xml
- About the author / method: https://www.shoppercove.com/about
Lab evidence
What I found running this
Lab 29 Sep 2026 IST. Local OCI image sha256:de23658418d8f8b507e13cb2f36e45d6fc394b2015b5e2671c74e3a656f36f91. Syft 1.18.1 SBOM (stdlib go1.24.4). Cosign v2.4.1 keyless blocked (OIDC); local-key sign Rekor 3003586630; SBOM attest Rekor 3003588212. Verify OK 0 / wrong-key 12 / unsigned 10 / wrong-identity 12.
Related links
Plate 11
After xz: A Practical Supply-Chain Checklist for Solo and Small Teams
Turn the xz-utils backdoor lesson into action: tarball vs git diffs, SBOM, Sigstore Cosign, SLSA provenance, and a solo/small-team release gate you can run this week.
29 Sept 2026
Plate 80
Lily in CI: Trying Fuzz-Based Backdoor Detection on a Real Repo
Hands-on Lily (ASE 2026) on an owned C toy: rosa/lily 0.6.0 pin, directed catch of a localhost-bind trigger, clean refactor with zero flags, discovery miss, and CI cost math.
30 Sept 2026
Plate 41
React 19.3 View Transitions & Fragment Refs: Frontend Guide (2026)
2 Oct 2026