Zion Boggan zionboggan.com ↗
87 lines · markdown
History for this file →
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
![Cosign sign and verify](docs/screenshots/01-cosign-sign-verify.png)
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).