ShopperCove
Menu
All writingBlogTopicsCategoriesAboutRSS
Blog
Categories
Observability & SRE62All categories
Topics
sre111localhost lab102performance70python58javascript47bundle size36benchmarking35node.js35web development21typescript19benchmark16content creation15All topics
About
Loading article…

ShopperCove

Hands-on notes on developer tools, written after the tool has actually been run.

All writingBlogCategoriesTopicsAboutPrivacyRSS

© 2026 ShopperCove

On this page

  1. Intro — what this post promises
  2. To sign an SBOM in CI, build the container image by digest, generate an SPDX or CycloneDX SBOM with Syft, sign the digest with Cosign, attach the SBOM with cosign attest --type spdxjson, and then run cosign verify against the expected key or GitHub OIDC identity. The step most setups skip is proving failure: in the lab, a wrong key exited 12 and an unsigned digest exited 10, and that non-zero exit is what should block the deploy.
  3. Goals for one workflow (not five half-finished jobs)
  4. Mental model — keyless Cosign in GitHub Actions
  5. Generate an SBOM (Syft — measured)
  6. End-to-end workflow shape (CI target + what the lab proved)
  7. Failure demo — the step most tutorials skip
  8. Deploy gate (minimum)
  9. What this workflow still does _not_ claim
  10. Worked mini-lab (filled)
  11. FAQ
  12. More reading
  13. Stay in touch
  14. [](https://www.shoppercove.com/about)FAQ
  15. What is the difference between signing an image and attesting an SBOM?
  16. What does keyless Cosign signing need in GitHub Actions?
  17. Does signing stop attacks like the xz backdoor?
  1. Blog

SBOM Signing in CI: How to Sign and Attest With Cosign

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.

29 September 2026·9 min read

Summary

Disclosure

This post contains affiliate links. If you buy through them, I may earn a commission at no extra cost to you. I only make firsthand claims about products I have actually used.

On this page
  1. Intro — what this post promises
  2. To sign an SBOM in CI, build the container image by digest, generate an SPDX or CycloneDX SBOM with Syft, sign the digest with Cosign, attach the SBOM with cosign attest --type spdxjson, and then run cosign verify against the expected key or GitHub OIDC identity. The step most setups skip is proving failure: in the lab, a wrong key exited 12 and an unsigned digest exited 10, and that non-zero exit is what should block the deploy.
  3. Goals for one workflow (not five half-finished jobs)
  4. Mental model — keyless Cosign in GitHub Actions
  5. Generate an SBOM (Syft — measured)
  6. End-to-end workflow shape (CI target + what the lab proved)
  7. Failure demo — the step most tutorials skip
  8. Deploy gate (minimum)
  9. What this workflow still does _not_ claim
  10. Worked mini-lab (filled)
  11. FAQ
  12. More reading
  13. Stay in touch
  14. [](https://www.shoppercove.com/about)FAQ
  15. What is the difference between signing an image and attesting an SBOM?
  16. What does keyless Cosign signing need in GitHub Actions?
  17. Does signing stop attacks like the xz backdoor?

Intro — what this post promises

To sign an SBOM in CI, build the container image by digest, generate an SPDX or CycloneDX SBOM with Syft, sign the digest with Cosign, attach the SBOM with cosign attest --type spdxjson, and then run cosign verify against the expected key or GitHub OIDC identity. The step most setups skip is proving failure: in the lab, a wrong key exited 12 and an unsigned digest exited 10, and that non-zero exit is what should block the deploy.

  1. Goals: sign, attest, verify (fail closed).
  2. Generate an SBOM next to the image.
  3. Cosign keyless via GitHub OIDC (id-token: write) — or a local key when OIDC is blocked, labeled honestly.
  4. Attach an SBOM attestation (Cosign attest in this lab).
  5. Demonstrate verify failure on purpose.
  6. 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:

  • 01shoppercove.comAfter xz: supply-chain checklist for solo and small teams
  • 02shoppercove.comxz-utils backdoor technical deep dive
  • 03shoppercove.comNot 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)

GoalPass criteriaLab 29 Sep 2026 IST
SignImage digest has a Cosign signatureLocal key → Rekor 3003586630
SBOMSPDX or CycloneDX from the imageSyft 1.18.1 → stdlib @ go1.24.4 + module
AttestSBOM attestation attachedCosign attest SPDX → Rekor 3003588212
VerifyExpected key/identity succeedscosign verify --key lab08.pub → exit 0
Fail demoWrong key / unsigned / wrong-identity failsexits 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:

  • 01docs.sigstore.devSigstore’s Cosign overview

In GitHub Actions you need (OIDC in Fulcio; CI quickstart):

Related links:

  • 01docs.sigstore.devOIDC in Fulcio
  • 02docs.sigstore.devCI quickstart
permissions:
  id-token: write   # mint OIDC token for Fulcio
  contents: read
  packages: write   # push to GHCR

Verify with identity + issuer, not “a signature exists”:

  • certificate-oidc-issuer: Open token.actions.githubusercontent.com
  • certificate-identity: Open github.com

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.

# Measured against local registry (HTTP)
SYFT_REGISTRY_INSECURE_USE_HTTP=true \
  syft scan "registry:127.0.0.1:5000/lab08/throwaway@sha256:de23658418d8f8b507e13cb2f36e45d6fc394b2015b5e2671c74e3a656f36f91" \
  -o spdx-json=sbom.spdx.json
Package (SPDX)Version
shoppercove.local/lab08-throwaway(devel)
stdlibgo1.24.4
image rootdigest sha256:de236584…

Official tooling: anchore/syft. We attached the SBOM as a Cosign attestation and kept the JSON as a lab artifact.

Related links:

  • 01github.comanchore/syft

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.

# CI TARGET (keyless) — lab used local key equivalent steps instead
name: build-sign-sbom
on:
  push:
    branches: [main]

jobs:
  build-sign:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write
    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: false

      - uses: sigstore/cosign-installer@v3.7.0
        # lab Cosign binary was v2.4.1; pin installer SHA in production

      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        id: build
        uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:ci-${{ github.sha }}

      - name: Generate SBOM (Syft 1.18.1 pin in lab)
        run: |
          curl -sSfL https://github.com/anchore/syft/releases/download/v1.18.1/syft_1.18.1_linux_amd64.tar.gz \
            | tar -xz -C /usr/local/bin syft
          syft scan "ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}" \
            -o spdx-json=sbom.spdx.json

      - name: Cosign sign (keyless in CI)
        env:
          DIGEST: ${{ steps.build.outputs.digest }}
          IMAGE: ghcr.io/${{ github.repository }}
        run: cosign sign --yes "${IMAGE}@${DIGEST}"

      - name: Cosign verify (expected identity)
        env:
          DIGEST: ${{ steps.build.outputs.digest }}
          IMAGE: ghcr.io/${{ github.repository }}
        run: |
          cosign verify "${IMAGE}@${DIGEST}" \
            --certificate-identity="https://github.com/${{ github.repository }}/.github/workflows/build-sign-sbom.yml@refs/heads/main" \
            --certificate-oidc-issuer="https://token.actions.githubusercontent.com"

      - name: Cosign attest SBOM
        env:
          DIGEST: ${{ steps.build.outputs.digest }}
          IMAGE: ghcr.io/${{ github.repository }}
        run: |
          cosign attest --yes --type spdxjson \
            --predicate sbom.spdx.json \
            "${IMAGE}@${DIGEST}"

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:

  • 01docs.github.comactions/attest

Local-key equivalent we ran (when keyless is blocked):

cosign sign --yes --allow-insecure-registry --key lab08.key \
  127.0.0.1:5000/lab08/throwaway@sha256:de23658418d8f8b507e13cb2f36e45d6fc394b2015b5e2671c74e3a656f36f91
# Rekor tlog index: 3003586630

cosign verify --allow-insecure-registry --key lab08.pub \
  127.0.0.1:5000/lab08/throwaway@sha256:de23658418d8f8b507e13cb2f36e45d6fc394b2015b5e2671c74e3a656f36f91
# exit 0

cosign attest --yes --allow-insecure-registry --key lab08.key \
  --type spdxjson --predicate sbom.spdx.json \
  127.0.0.1:5000/lab08/throwaway@sha256:de23658418d8f8b507e13cb2f36e45d6fc394b2015b5e2671c74e3a656f36f91
# Rekor tlog index: 3003588212

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

# Wrong key — measured NON-ZERO (exit 12)
cosign verify --allow-insecure-registry --key wrong.pub \
  127.0.0.1:5000/lab08/throwaway@sha256:de23658418d8f8b507e13cb2f36e45d6fc394b2015b5e2671c74e3a656f36f91

# Unsigned digest — measured NON-ZERO (exit 10)
cosign verify --allow-insecure-registry --key lab08.pub \
  127.0.0.1:5000/lab08/throwaway@sha256:3e64b43d08306527b3290eeeddef736f675f439ec0aec0504e702d272bc281a5

# Wrong identity flags on a key-signed image — measured NON-ZERO (exit 12)
cosign verify --allow-insecure-registry \
  --certificate-identity="https://github.com/NOT-US/other/.github/workflows/ci.yml@refs/heads/main" \
  --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
  127.0.0.1:5000/lab08/throwaway@sha256:de23658418d8f8b507e13cb2f36e45d6fc394b2015b5e2671c74e3a656f36f91
CaseExpected exitLab result
Correct local key00
Wrong key≠012 (PEM mismatch / no matching signatures)
Unsigned digest≠010 (no signatures found)
Wrong certificate-identity (on key-signed image)≠012 (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:

  1. Deploy references digest.
  2. cosign verify in CD or admission policy.
  3. 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 avoidWhy
“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:

  • 01shoppercove.comAfter xz: supply-chain checklist
  • 02shoppercove.comLily / fuzz-based defense

Worked mini-lab (filled)

StepArtifact / signalLab status
Throwaway image by digest127.0.0.1:5000/lab08/throwaway@sha256:de236584…Done (local OCI; not GHCR)
Syft SBOMlab08-throwaway.spdx.json (stdlib go1.24.4)Done
Cosign signRekor 3003586630Done (local key)
Verify successexit 0Done
Verify failwrong-key 12, unsigned 10Done
AttestationCosign attest SPDX, Rekor 3003588212, verify-attestation 0Done
Keyless OIDCdevice-flow timeout exit 124Blocked (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:

  • 01shoppercove.comNot Git Yard / fuzz-based defense

More reading

  • 01shoppercove.comAfter xz: a practical supply-chain checklist for solo and small teams
  • 02shoppercove.comxz-utils backdoor technical deep dive
  • 03shoppercove.comNot Git Yard / fuzz-based defense
  • 04shoppercove.comAbout ShopperCove
  • 05docs.sigstore.devCosign signing overview
  • 06docs.sigstore.devCosign CI quickstart
  • 07docs.sigstore.devOIDC in Fulcio
  • 08docs.github.comGitHub artifact attestations
  • 09github.comSyft
  • 10slsa.devSLSA Build levels

Stay in touch

  • Subscribe via RSS: Open shoppercove.com
  • About the author / method: Open shoppercove.com

FAQ

What is the difference between signing an image and attesting an SBOM?

Signing proves who built the image digest. An SBOM attestation attaches a signed inventory of what is inside it. Signing alone doesn't tell you the contents, and an SBOM is not a malware scan.

What does keyless Cosign signing need in GitHub Actions?

permissions: id-token: write so the workflow can mint an OIDC token for Sigstore's Fulcio, which issues a short-lived certificate. Rekor records the signing event. Verify with --certificate-identity and --certificate-oidc-issuer.

Does signing stop attacks like the xz backdoor?

No. Signing your own image proves who built it, not that upstream code is clean. The post pairs it with tarball-vs-git diffs and fuzz-based checks for that class of attack.

cosignsbomsyftsigstoregithub actionscontainer signingsupply chain securityattestation

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.

Explore the product

Untested by us. Check what's included, the license and refund terms before you buy.

Ready Client Sites for prebuilt websites

See Ready Client Sites details

Notes when a lab post goes up

Occasional email for new hands-on reviews. No sequence and no sponsors.

Explore the product

Untested by us. Check what's included, the license and refund terms before you buy.

Ready Client Sites for prebuilt websites

See Ready Client Sites details

Keep reading

  • Supply Chain Security Checklist for Small Teams After xz

    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.

    Summary29 Sept 202610 min read

  • 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.

    Summary30 Sept 20268 min read

  • Python partition vs split: Which One Should You Use?

    Both cut a string at a separator, but they fail in different ways. We ran them on Python 3.13.5 to show where partition() is safer and where split() is the right tool.

    Summary2 Nov 20263 min read