ShopperCove
Menu
All writingBlogTopicsCategoriesAboutRSS
Blog
Categories
Observability & SRE62All categories
About

Plate 13

  1. Blog

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 Challa·29 September 2026·8 min read

Summary
On this page
  1. Intro — what this post promises
  2. Goals for one workflow (not five half-finished jobs)
  3. Mental model — keyless Cosign in GitHub Actions
  4. Generate an SBOM (Syft — measured)
  5. End-to-end workflow shape (CI target + what the lab proved)
  6. Failure demo — the step most tutorials skip
  7. Deploy gate (minimum)
  8. What this workflow still does _not_ claim
  9. Worked mini-lab (filled)
  10. FAQ
  11. More reading
  12. 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:

  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:

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

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:

  • Sigstore’s Cosign overview

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

Related links:

  • OIDC in Fulcio
  • CI 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: https://token.actions.githubusercontent.com
  • certificate-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.

# 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:

  • anchore/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:

  • actions/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:

  • After xz: supply-chain checklist
  • Lily / 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:

  • Not Git Yard / fuzz-based defense

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

Notes when a lab post goes up

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

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

On this page

  1. Intro — what this post promises
  2. Goals for one workflow (not five half-finished jobs)
  3. Mental model — keyless Cosign in GitHub Actions
  4. Generate an SBOM (Syft — measured)
  5. End-to-end workflow shape (CI target + what the lab proved)
  6. Failure demo — the step most tutorials skip
  7. Deploy gate (minimum)
  8. What this workflow still does _not_ claim
  9. Worked mini-lab (filled)
  10. FAQ
  11. More reading
  12. Stay in touch
All writingBlogCategoriesTopicsAboutPrivacyRSS

© 2026 ShopperCove