| 1 | # CI/CD Supply Chain Security |
| 2 | |
| 3 | This picks up where the [secure CI/CD pipeline](../secure-cicd-pipeline) leaves off. |
| 4 | That repo proves the *source* is clean. This one proves the *artifact* is - that the |
| 5 | container image a cluster is about to run was built by my pipeline, hasn't been |
| 6 | tampered with since, and ships with a verifiable bill of materials. |
| 7 | |
| 8 | The mechanism is Sigstore. The pipeline signs every image keylessly with Cosign |
| 9 | using the workflow's OIDC identity, attaches an SBOM as a signed attestation, and a |
| 10 | Kyverno policy refuses to admit anything to the cluster that can't produce both. |
| 11 | |
| 12 | ```mermaid |
| 13 | flowchart LR |
| 14 | build[build image] --> sbom[syft SBOM] |
| 15 | sbom --> scan[grype scan<br/>fail on high] |
| 16 | scan --> sign[cosign sign<br/>keyless OIDC] |
| 17 | sign --> attest[cosign attest<br/>SBOM] |
| 18 | attest --> verify[cosign verify<br/>in-pipeline] |
| 19 | verify --> admit[Kyverno admission<br/>at deploy time] |
| 20 | ``` |
| 21 | |
| 22 | ## Keyless signing |
| 23 | |
| 24 | There is no private key to manage or leak. Cosign requests a short-lived |
| 25 | certificate from Fulcio bound to the GitHub Actions OIDC identity |
| 26 | (`https://github.com/<owner>/<repo>`), signs, and logs the signature to the Rekor |
| 27 | transparency log. Verification checks the certificate identity and issuer rather |
| 28 | than a key you have to rotate. |
| 29 | |
| 30 | That is why the workflow asks for `id-token: write` - without it there's no OIDC |
| 31 | token to exchange for the signing certificate. |
| 32 | |
| 33 | Cosign signing and verification, and what happens the moment a signed artifact is |
| 34 | modified - the signature is rejected: |
| 35 | |
| 36 |  |
| 37 | |
| 38 | > The screenshot uses a local key pair to show the sign/verify mechanics offline; |
| 39 | > the pipeline itself signs keylessly with the GitHub OIDC identity (no stored key). |
| 40 | |
| 41 | ## What the pipeline does |
| 42 | |
| 43 | | Stage | Tool | Output | |
| 44 | |-------|------|--------| |
| 45 | | build | buildx + build-push-action | image pushed to GHCR by digest, with provenance + SBOM attestations | |
| 46 | | scan | syft + grype | SPDX SBOM artifact; build fails on a high/critical CVE | |
| 47 | | sign | cosign | keyless signature in Rekor | |
| 48 | | attest | cosign | signed SPDX attestation attached to the image | |
| 49 | | verify | cosign | signature + attestation checked before the run is called good | |
| 50 | |
| 51 | Everything operates on the image **digest**, never a mutable tag, so the thing that |
| 52 | gets signed and verified is exactly the thing that was built. |
| 53 | |
| 54 | ## Enforcing it at deploy time |
| 55 | |
| 56 | `policy/kyverno-verify-images.yaml` is a `ClusterPolicy` in `Enforce` mode that: |
| 57 | |
| 58 | 1. requires a Cosign signature from this repo's GitHub identity, and |
| 59 | 2. requires an SPDX SBOM attestation from the same identity, |
| 60 | |
| 61 | for any image under `ghcr.io/zionboggan/*`. It also rewrites tags to digests on |
| 62 | admission (`mutateDigest: true`) so a pod can't be pinned to a tag that later moves. |
| 63 | |
| 64 | Apply it: |
| 65 | |
| 66 | ```bash |
| 67 | kubectl apply -f policy/kyverno-verify-images.yaml |
| 68 | ``` |
| 69 | |
| 70 | A signed image from the pipeline is admitted; an arbitrary `nginx:latest` is |
| 71 | rejected. `policy/test/pods.yaml` has one of each for `kyverno apply` to test |
| 72 | against, which is what the `admission-policy-check` workflow runs on PRs that touch |
| 73 | the policy. |
| 74 | |
| 75 | ## Verify an image by hand |
| 76 | |
| 77 | ```bash |
| 78 | OWNER=zionboggan ./policy/verify.sh ghcr.io/zionboggan/cicd-supply-chain-security@sha256:<digest> |
| 79 | ``` |
| 80 | |
| 81 | ## Notes |
| 82 | |
| 83 | Keyless is the right default for CI - nothing to store, everything in the |
| 84 | transparency log. If you needed an air-gapped or offline verifier you'd switch to a |
| 85 | key pair (or a KMS key) and Cosign supports that, but for a GitHub-hosted pipeline |
| 86 | the OIDC identity is the cleaner trust anchor. More in |
| 87 | [docs/supply-chain.md](docs/supply-chain.md). |