Supply-chain security: SBOM, signed images & SLSA provenance¶
secsy-pki hardens the release pipeline for the container image and binaries (built in Kubernetes deployment) so that consumers can prove, before they run it, what they are running and where it came from:
- a Software Bill of Materials (SBOM) — CycloneDX — for both the Go modules and the container image, so every dependency is enumerable and scannable;
- a cosign signature on the image (keyless / Sigstore OIDC by default, or a configurable key), so tampered or unofficial images are rejected;
- a cosign SBOM attestation, binding the SBOM to the exact image digest;
- a SLSA build-provenance attestation (SLSA Build L3) attached to the image,
produced by the official
slsa-github-generator, so the build is traceable to the source commit and workflow; - a
govulncheckgate in CI that fails the build on any reachable vulnerability in the Go dependency tree.
Everything is driven from the repo Makefile and two GitHub
Actions workflows, so what CI does and what you run locally cannot drift.
- Build/sign/verify targets:
Makefile - Release pipeline (tags):
.github/workflows/release.yaml - Continuous gate + SBOM:
.github/workflows/supply-chain.yaml
1. What is produced on a release¶
Pushing a v* tag (e.g. v1.2.3) runs release.yaml, which publishes to GHCR:
| Artifact | Type | How it is attached |
|---|---|---|
ghcr.io/<owner>/secsy-pki:<tag> (+ :latest) |
OCI image | pushed by digest |
| Image signature | cosign .sig |
cosign sign (keyless or key) |
| Image SBOM | CycloneDX attestation | cosign attest --type cyclonedx |
| Go-module SBOM | CycloneDX file | GitHub Release asset + workflow artifact |
| SLSA provenance | in-toto SLSA v0.2 attestation | slsa-github-generator, attached to the image |
The pipeline verifies all of this itself (the verify job) before the run
is considered green — the same commands a consumer runs, below.
2. Consumer verification (the important part)¶
Install cosign and, for
provenance, slsa-verifier.
Replace <owner> and the tag as appropriate. Always verify by digest in
automation — resolve the tag to a digest first:
IMAGE=ghcr.io/<owner>/secsy-pki
TAG=v1.2.3
DIGEST=$(cosign triangulate --type digest "$IMAGE:$TAG" 2>/dev/null \
|| crane digest "$IMAGE:$TAG")
REF="$IMAGE@$DIGEST"
2a. Verify the signature (keyless / Sigstore)¶
Keyless signatures are bound to the GitHub Actions workflow identity that produced them, so pin both the identity and the OIDC issuer — this is what stops an attacker's signature from a different repo being accepted:
cosign verify \
--certificate-identity-regexp "^https://github.com/<owner>/secsy-pki/" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"$REF"
2b. Verify the SBOM attestation and read the SBOM¶
# Verify the attestation signature + identity, then extract the CycloneDX doc:
cosign verify-attestation \
--type cyclonedx \
--certificate-identity-regexp "^https://github.com/<owner>/secsy-pki/" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"$REF" \
| jq -r '.payload | @base64d | fromjson | .predicate' > sbom.cdx.json
# Now scan it with any CycloneDX-aware tool, e.g. grype:
grype sbom:sbom.cdx.json
2c. Verify the SLSA build provenance¶
The provenance is signed by the SLSA generator's own workflow identity; use
slsa-verifier, which knows how to check it and pins the source repo:
slsa-verifier verify-image "$REF" \
--source-uri "github.com/<owner>/secsy-pki" \
--source-tag "$TAG" \
--print-provenance | jq .
You can also read the raw provenance attestation with cosign (identity is the generator's reusable workflow, not this repo):
cosign verify-attestation \
--type slsaprovenance \
--certificate-identity-regexp "^https://github.com/slsa-framework/slsa-github-generator/" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"$REF" | jq -r '.payload | @base64d | fromjson | .predicate'
2d. Enforce it in the cluster¶
Verification is only worth something if unsigned images are actually rejected. Enforce the policy at admission with sigstore-policy-controller or Kyverno. Minimal Kyverno example (keyless, pinned identity):
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-secsy-pki-signature
spec:
validationFailureAction: Enforce
rules:
- name: verify-signature
match:
any:
- resources:
kinds: [Pod]
verifyImages:
- imageReferences: ["ghcr.io/<owner>/secsy-pki*"]
attestors:
- entries:
- keyless:
subject: "https://github.com/<owner>/secsy-pki/*"
issuer: "https://token.actions.githubusercontent.com"
3. Producing the artifacts locally (make)¶
The Makefile exposes the same steps CI uses. It needs docker, cosign and
syft on PATH (make tools installs cosign + syft); the Go-native tools
(cyclonedx-gomod, govulncheck) are pinned and run via go run.
make help # list targets + current IMAGE/VERSION
make govulncheck # gating vulnerability scan of the Go deps
make sbom IMAGE=… VERSION=… # dist/sbom-gomod.cdx.json + sbom-image.cdx.json
make image IMAGE=… VERSION=… # docker build
make sign IMAGE=… VERSION=… \ # cosign sign + SBOM attest
IMAGE_DIGEST=sha256:…
make verify IMAGE=… VERSION=… \ # cosign verify + verify-attestation
IMAGE_DIGEST=sha256:…
Keyless vs. key-based signing. By default the targets sign keyless
(Fulcio/Rekor OIDC), which is what you want in GitHub Actions. To sign with a
configurable key set COSIGN_KEY (and COSIGN_PASSWORD):
cosign generate-key-pair # -> cosign.key / cosign.pub
make sign IMAGE=$IMAGE VERSION=$TAG IMAGE_DIGEST=$DIGEST \
COSIGN_KEY=cosign.key
make verify IMAGE=$IMAGE VERSION=$TAG IMAGE_DIGEST=$DIGEST \
COSIGN_VERIFY_FLAGS="--key cosign.pub"
COSIGN_KEY accepts anything cosign does, including KMS references
(awskms://…, azurekms://…, hashivault://…) and k8s://… secrets — so the
signing key itself can live in the same HSM/KMS backend used for CA keys
(see Cloud KMS backend).
4. The govulncheck gate¶
make govulncheck runs govulncheck in source/call-graph mode over the whole
server module (-tags sqlite). Unlike a naïve dependency diff it only fails on
vulnerabilities that are actually reachable from the code, keeping the gate
low-noise. It runs:
- on every push/PR to
main(supply-chain.yaml), and - as a pre-publish gate on every release (
release.yaml) — no signed image is produced if a reachable vulnerability is present.
Because standard-library vulnerabilities are reported against the Go toolchain
version, the toolchain is pinned to a patched release via the toolchain
directive in server/go.mod. When govulncheck flags a new
stdlib CVE, bump that directive to the fixed patch release; for module CVEs,
go get the fixed dependency version.
The optional zlint backend¶
The industry-standard github.com/zmap/zlint pre-issuance lint backend (see
certlint.md) is compiled in only under the zlint build tag.
Its modules (zmap/zlint, zmap/zcrypto, and two transitive deps) appear in
go.mod — go mod tidy records any import reachable under any build tag — but
they are not linked into, or reachable from, the default/FIPS/supply-chain
builds. Consequently the default make govulncheck (-tags sqlite) neither
analyzes nor is affected by them. To scan the zlint dependency tree, run the
reachability check with the tag:
Pipelines that ship a -tags zlint build should add this scan. The module SBOM
(cyclonedx-gomod mod) lists the zmap modules because they are in the module
graph; a per-binary SBOM of a default build does not.
5. How it fits together in CI¶
any push ─▶ container.yaml (the only workflow that builds/pushes the image)
├─ build: build both architectures, run them, no credential held
├─ publish: push ─▶ make sbom-image ─▶ make sign (cosign) ─▶ make verify
└─ verify: pull it back anonymously, by tag, with no permissions
tag v* ──▶ release.yaml
├─ guard: tag + CHANGELOG.md + go.mod agree, or nothing runs
├─ ci: the whole SoftHSM suite, called at this commit
├─ govulncheck: (gate; nothing published on failure)
├─ binaries: release archives from the image's own Dockerfile
│ └─ make sbom-gomod ─▶ Release assets
├─ verify: the archives, unpacked and run
├─ image: calls container.yaml ─▶ signed image + SBOM attestation
├─ provenance: slsa-github-generator ─▶ SLSA attestation on the image
│ attest-build-provenance ─▶ provenance on the archives
└─ release: the GitHub release, last
push/PR ──▶ supply-chain.yaml
├─ govulncheck (gate)
└─ sbom (Go-module CycloneDX SBOM artifact)
The image is signed on every publish, not only on releases: an admission
policy that requires a signature cannot make an exception for edge, and a
signing path exercised only at release time is a signing path that breaks at
release time. See releasing for the release order and
the container image for the tags.
See the operator runbook for the incident procedure when verification fails in the field, and Kubernetes deployment for where the image is consumed.