Keyless signing with Sigstore
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 the image, and logs the signature to the Rekor transparency log. That is why the workflow requests id-token: write - without it there is no OIDC token to exchange for a signing certificate. The sign job runs verbatim:
- name: sign image keyless
run: cosign sign --yes ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }}
- uses: actions/download-artifact@v4
with:
name: sbom
- name: attach sbom attestation
run: |
cosign attest --yes \
--predicate sbom.spdx.json \
--type spdxjson \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }}Verification checks the certificate identity and issuer rather than a key you have to rotate. An image built somewhere else simply cannot produce a signature from this repo's identity.
SBOM + scanning
After the image is built and pushed to GHCR by digest, the scan job uses Anchore's syft to generate an SPDX-JSON SBOM from the actual pushed image, then grype scans that image. The build fails on any high or critical CVE, so an unsigned-and-vulnerable image never reaches the registry as a release. Both steps target the digest, not the tag:
- id: sbom
uses: anchore/sbom-action@v0
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }}
format: spdx-json
output-file: sbom.spdx.json
- uses: anchore/scan-action@v5
id: grype
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }}
fail-build: true
severity-cutoff: highThe same two tools drive local reproduction through the Makefile - syft $(IMAGE) -o spdx-json=sbom.spdx.json for the SBOM and grype $(IMAGE) --fail-on high for the scan - so the gate behaves identically on a laptop and in CI. The resulting sbom.spdx.json is uploaded as a workflow artifact and handed to the sign job, which attaches it as a signed Cosign attestation of type spdxjson, tying the contents to the same OIDC identity that signed the image.
In-pipeline verification (fail-closed)
Signing isn't trusted on faith. A dedicated verify job depends on both build and sign and re-checks the signature and the SBOM attestation against the certificate identity and issuer before the run is considered successful. If either check fails, the job exits non-zero and the run is red:
- name: verify signature and provenance
run: |
cosign verify \
--certificate-identity-regexp "^https://github.com/${{ github.repository_owner }}/" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }}
- name: verify sbom attestation
run: |
cosign verify-attestation \
--type spdxjson \
--certificate-identity-regexp "^https://github.com/${{ github.repository_owner }}/" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }}Verification is matched against a regexp on the certificate subject (^https://github.com/<owner>/) and an exact OIDC issuer (https://token.actions.githubusercontent.com). There is no anonymous or unverified path: an image with no signature, a signature from a different identity, or a missing SBOM attestation all produce a non-zero exit. The same logic is factored into policy/verify.sh for verifying any image by hand.
Kyverno admission policy
policy/kyverno-verify-images.yaml is a Kyverno ClusterPolicy in Enforce mode (failurePolicy: Fail, background: false) that gates every Pod whose image matches ghcr.io/zionsworking/*. The first rule requires a keyless Cosign signature verified against the public Rekor instance, rewrites the tag to a digest on admission, and marks the check required: true:
spec:
validationFailureAction: Enforce
webhookTimeoutSeconds: 30
failurePolicy: Fail
background: false
rules:
- name: check-cosign-signature
match:
any:
- resources:
kinds:
- Pod
verifyImages:
- imageReferences:
- "ghcr.io/zionsworking/*"
attestors:
- count: 1
entries:
- keyless:
subject: "https://github.com/zionsworking/*"
issuer: "https://token.actions.githubusercontent.com"
rekor:
url: https://rekor.sigstore.dev
mutateDigest: true
verifyDigest: true
required: trueThe second rule, require-sbom-attestation, demands an SPDX attestation (type: https://spdx.dev/Document) from that same keyless identity, so no image is admitted without a verifiable bill of materials:
- name: require-sbom-attestation
match:
any:
- resources:
kinds:
- Pod
verifyImages:
- imageReferences:
- "ghcr.io/zionsworking/*"
attestations:
- type: https://spdx.dev/Document
attestors:
- count: 1
entries:
- keyless:
subject: "https://github.com/zionsworking/*"
issuer: "https://token.actions.githubusercontent.com"Because mutateDigest: true rewrites tags to digests at admission, a Pod can't be pinned to a tag that later moves. A signed image from the pipeline is admitted; an arbitrary nginx:latest is rejected.
Tamper detection
The moment a signed artifact is modified, the signature no longer matches the digest and verification is rejected. The demo re-tags or rebuilds the image so its content (and therefore its digest) changes, then runs the same verifier - which now fails because no Fulcio certificate in Rekor is bound to the new digest:
$ cosign verify \
--certificate-identity-regexp "^https://github.com/zionsworking/" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/zionsworking/cicd-supply-chain-security@sha256:<tampered-digest>
Error: no matching signatures:
main.go:74: error during command execution: no matching signatures:The screenshot below demonstrates the same mechanics offline with a local key pair: a valid cosign verify against the signed image, then the rejection the instant the artifact is altered. Because every step - build output, scan target, sign, attest, verify, and the Kyverno admission rewrite - operates on @sha256:..., signing a tag never silently degrades to signing a mutable pointer, and tampering is always caught at the digest boundary.
Tamper resistance and transparency
This layer defends against the attacks where the source is fine but the artifact isn't, and each maps to a concrete control:
| Attack | Control |
|---|---|
| Registry account compromised, tag repointed at a malicious image | Kyverno resolves tags to digests on admission and requires a signature over that digest; moving a tag doesn't move the signature |
| Build runner tampered with, producing a backdoored image | Signature is tied to the workflow's OIDC identity; an image built elsewhere can't sign as https://github.com/zionsworking/... |
| Dependency with a known high/critical CVE pulled at build time | grype scans the built image and fails the build before signing happens |
| Image deployed whose contents nobody can account for | SPDX SBOM generated from the actual image, signed as an attestation, required at admission - no SBOM, no deploy |
Every signature and attestation is recorded in Rekor, giving an append-only, auditable record of what was signed, by which identity, and when. If a signing identity is ever misused, the log is where you would find every artifact it touched.
Testing the policy in CI
A separate admission-policy-check workflow runs on pull requests that touch policy/**. It installs a pinned Kyverno CLI (v1.12.5) and runs the policy against policy/test/pods.yaml, which contains one signed Pod (ghcr.io/zionsworking/cicd-supply-chain-security) and one unsigned docker.io/library/nginx:latest Pod, so the allow and deny paths are both exercised before the policy can change:
- name: install kyverno cli
run: |
curl -sLo kyverno.tar.gz https://github.com/kyverno/kyverno/releases/download/v1.12.5/kyverno-cli_v1.12.5_linux_x86_64.tar.gz
tar -xzf kyverno.tar.gz kyverno
sudo install kyverno /usr/local/bin/
- name: validate policy
run: kyverno apply policy/kyverno-verify-images.yaml --resource policy/test/pods.yamlThe same steps are wired into the Makefile targets (build, sbom, scan, sign, verify, policy-test) for local reproduction, with policy-test calling the identical kyverno apply invocation.
