This picks up where the secure CI/CD pipeline leaves off. That repo proves the *source* is clean. This one proves the *artifact* is - that the container image a cluster is about to run was built by my pipeline, hasn't been tampered with since, and ships with a verifiable bill ...
This picks up where the secure CI/CD pipeline leaves off. That repo proves the source is clean. This one proves the artifact is - that the container image a cluster is about to run was built by my pipeline, hasn't been tampered with since, and ships with a verifiable bill of materials.
The mechanism is Sigstore. The pipeline signs every image keylessly with Cosign using the workflow's OIDC identity, attaches an SBOM as a signed attestation, and a Kyverno policy refuses to admit anything to the cluster that can't produce both.
flowchart LR
build[build image] --> sbom[syft SBOM]
sbom --> scan[grype scan<br/>fail on high]
scan --> sign[cosign sign<br/>keyless OIDC]
sign --> attest[cosign attest<br/>SBOM]
attest --> verify[cosign verify<br/>in-pipeline]
verify --> admit[Kyverno admission<br/>at deploy time]
There is no private key to manage or leak. Cosign requests a short-lived
certificate from Fulcio bound to the GitHub Actions OIDC identity
(https://github.com/<owner>/<repo>), signs, and logs the signature to the Rekor
transparency log. Verification checks the certificate identity and issuer rather
than a key you have to rotate.
That is why the workflow asks for id-token: write - without it there's no OIDC
token to exchange for the signing certificate.
Cosign signing and verification, and what happens the moment a signed artifact is modified - the signature is rejected:

The screenshot uses a local key pair to show the sign/verify mechanics offline; the pipeline itself signs keylessly with the GitHub OIDC identity (no stored key).
| Stage | Tool | Output |
|---|---|---|
| build | buildx + build-push-action | image pushed to GHCR by digest, with provenance + SBOM attestations |
| scan | syft + grype | SPDX SBOM artifact; build fails on a high/critical CVE |
| sign | cosign | keyless signature in Rekor |
| attest | cosign | signed SPDX attestation attached to the image |
| verify | cosign | signature + attestation checked before the run is called good |
Everything operates on the image digest, never a mutable tag, so the thing that gets signed and verified is exactly the thing that was built.
policy/kyverno-verify-images.yaml is a ClusterPolicy in Enforce mode that:
for any image under ghcr.io/zionboggan/*. It also rewrites tags to digests on
admission (mutateDigest: true) so a pod can't be pinned to a tag that later moves.
Apply it:
kubectl apply -f policy/kyverno-verify-images.yaml
A signed image from the pipeline is admitted; an arbitrary nginx:latest is
rejected. policy/test/pods.yaml has one of each for kyverno apply to test
against, which is what the admission-policy-check workflow runs on PRs that touch
the policy.
OWNER=zionboggan ./policy/verify.sh ghcr.io/zionboggan/cicd-supply-chain-security@sha256:<digest>
Keyless is the right default for CI - nothing to store, everything in the transparency log. If you needed an air-gapped or offline verifier you'd switch to a key pair (or a KMS key) and Cosign supports that, but for a GitHub-hosted pipeline the OIDC identity is the cleaner trust anchor. More in docs/supply-chain.md.