Zion Boggan

In-depth vulnerability research, detection engineering & applied cryptography.

● Open to security-research & detection roles
GitHub · LinkedIn · Email
← All work
SUPPLY CHAIN

CI/CD Supply-Chain Security

Proves the artifact, not just the source: keyless Cosign signing, a signed SPDX SBOM, grype scanning, and a Kyverno admission policy that refuses anything it can't verify.

CosignSigstoresyftgrypeKyvernoRekorFulcioGitHub ActionsGHCRSPDX
4
Chained CI jobs: build, scan, sign, verify
0
Private keys to store (keyless Fulcio OIDC)
SPDX
SBOM format (spdx-json), signed as attestation
high
grype severity-cutoff that fails the build
2
Kyverno rules: signature + SBOM, both required
1.12.5
Kyverno CLI pinned for policy tests in CI
Cosign sign and verify mechanics, and the rejection that follows the moment a signed artifact is modified.
Cosign sign and verify mechanics, and the rejection that follows the moment a signed artifact is modified.

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: high

The 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: true

The 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:

AttackControl
Registry account compromised, tag repointed at a malicious imageKyverno 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 imageSignature 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 timegrype scans the built image and fails the build before signing happens
Image deployed whose contents nobody can account forSPDX 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.yaml

The 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.

Repository · github.com/zionsworking/cicd-supply-chain-security