Enforce Supply Chain Security Using Attestations and Policy-as-Code
Ingredients
- Install Syft
- Install Grype
- Install Go
- Install Vexctl
- Install Cosign
- Install Docker
- Install Kubectl
- Install KinD
- Install Helm
- Install Kyverno
Recipe
Initialize Your Environment Variables
Step 1. Initialize all the environment variables that will be used. The REGISTRY_PORT environment variable must be set to an port number that doesn't conflict the port Docker's local container registry is listening on (i.e., you cannot use 5000).
export REGISTRY_NAME="demo-registry"
export REGISTRY_PORT="5001"
export REGISTRY_INTERNAL_PORT="5000"
export CLUSTER_NAME="demo-cluster"
export COSIGN_PASSWORD="demo"
Deploy a Container Registry
Step 1. Create a public and private key pair for the container registry you're about deploy.
openssl req -x509 -nodes -days 365 \
-newkey rsa:4096 \
-out "${REGISTRY_NAME}.crt" \
-keyout "${REGISTRY_NAME}.key" \
-subj "/CN=${REGISTRY_NAME}" \
-addext "subjectAltName=DNS:${REGISTRY_NAME},DNS:localhost"
Step 2. Copy your container registry's public key to your local CA store.
sudo cp ${REGISTRY_NAME}.crt /usr/local/share/ca-certificates/${REGISTRY_NAME}.crt
Step 3. Update your local CA store.
sudo update-ca-certificates
Step 4. Deploy a container registry locally called using docker and the key pair you just created.
docker run -d \
--name ${REGISTRY_NAME} \
-p ${REGISTRY_PORT}:${REGISTRY_INTERNAL_PORT} \
-v $(pwd)/${REGISTRY_NAME}.crt:/certs/domain.crt \
-v $(pwd)/${REGISTRY_NAME}.key:/certs/domain.key \
-e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \
-e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \
registry:3
Step 5. Verify the container registry is running by querying it from your local machine.
curl https://localhost:${REGISTRY_PORT}/v2/_catalog
You should get output similar to below.
{"repositories":[]}
Deploy a Kubernetes Cluster
Step 1. Deploy a Kubernetes cluster called demo using kind.
kind create cluster --name ${CLUSTER_NAME} --image kindest/node:v1.34.0 --config cluster.yaml
Step 2. Confirm the version of your Kubernetes cluster is v1.34.0 using kubectl.
kubectl version
You should get output similar to below.
Client Version: v1.35.0
Kustomize Version: v5.7.1
Server Version: v1.34.0
Step 3. Add your container registry's public key to each of the nodes in your Kubernetes cluster.
docker cp ${REGISTRY_NAME}.crt ${CLUSTER_NAME}-control-plane:/usr/local/share/ca-certificates/${REGISTRY_NAME}.crt
docker cp ${REGISTRY_NAME}.crt ${CLUSTER_NAME}-worker:/usr/local/share/ca-certificates/${REGISTRY_NAME}.crt
docker cp ${REGISTRY_NAME}.crt ${CLUSTER_NAME}-worker2:/usr/local/share/ca-certificates/${REGISTRY_NAME}.crt
Step 4. Update the CA store on each of the nodes in your Kubernetes cluster.
docker exec ${CLUSTER_NAME}-control-plane update-ca-certificates
docker exec ${CLUSTER_NAME}-worker update-ca-certificates
docker exec ${CLUSTER_NAME}-worker2 update-ca-certificates
Connect the Kubernetes Cluster to the Container Registry
Step 1. Identify the configuration your Kubernetes cluster's network is using.
docker network inspect kind | jq ".[0].IPAM"
You should get output similar to below. Pay special attention to the IPv4 Subnet field.
{
"Driver": "default",
"Options": {},
"Config": [
{
"Subnet": "fc00:f853:ccd:e793::/64"
},
{
"Subnet": "172.18.0.0/16",
"Gateway": "172.18.0.1"
}
]
}
Step 2. Connect your Kubernetes cluster's network (e.g., kind) to your container registry's network. Do not skip this step or your Kubernetes cluster will not be able to resolve the hostname of your container registry.
docker network connect kind ${REGISTRY_NAME}
Install Kyverno on the Kubernetes Cluster
Step 1. Add the URL for the Kyverno Helm chart repo to your local Helm configuration.
helm repo add kyverno https://kyverno.github.io/kyverno/
helm repo update
Step 2. Either use the provided kyverno-values.yaml file or recreate it. If you recreate it, make sure the content below is in the file. This will ensure the ca-certificates.crt file of the node each Kyverno pod is running on is mounted as a file volume.
---
global:
caCertificates:
volume:
hostPath:
path: /etc/ssl/certs/ca-certificates.crt
type: File
Step 3. Install Kyverno on the Kubernetes cluster.
helm install kyverno kyverno/kyverno \
--create-namespace \
-n kyverno \
-f kyverno-values.yaml
Step 4. Wait for the Kyverno pods to start.
kubectl -n kyverno get pods
You should eventually see output similar to below.
NAME READY STATUS RESTARTS AGE
kyverno-admission-controller-5f9fb8dcb8-zg57j 1/1 Running 0 41s
kyverno-background-controller-79b78db6b-w7dq2 1/1 Running 0 41s
kyverno-cleanup-controller-6bcc48b5-k4c2k 1/1 Running 0 41s
kyverno-reports-controller-5dbc78665-t9ksf 1/1 Running 0 41s
Create a Key Pair for cosign
Step 1. Create another public and private key pair albeit for cosign to use.
cosign generate-key-pair
Step 2. Replace the public key in the provided Kyverno policy with the one generated in the previous step.
Create and Apply a Kyverno Policy
Step 1. Add the contents of cosign.pub to kyverno-policy.yaml and then, apply a Kyverno policy to the Kubernetes cluster.
kubectl apply -f kyverno-policy.yaml
The output should be similar to below.
clusterpolicy.kyverno.io/check-images created
Pull, Tag, and Push a Container Image to the Container Registry
Step 1. Pull a container image.
docker pull vulnerables/web-dvwa:latest
The output should be similar to below.
latest: Pulling from vulnerables/web-dvwa
eb05d18be401: Pull complete
b3d64a33242d: Pull complete
e9968e5981d2: Pull complete
2cd72dba8257: Pull complete
6cff5f35147f: Pull complete
098cffd43466: Pull complete
3e17c6eae66c: Pull complete
0c57df616dbf: Pull complete
Digest: sha256:dae203fe11646a86937bf04db0079adef295f426da68a92b40e3b181f337daa7
Status: Downloaded newer image for vulnerables/web-dvwa:latest
docker.io/vulnerables/web-dvwa:latest
Step 2. Tag the container image.
docker tag vulnerables/web-dvwa:latest localhost:${REGISTRY_PORT}/web-dvwa:latest
Step 3. Push the container image to your container registry.
docker push localhost:${REGISTRY_PORT}/web-dvwa:latest
You should get output similar to below.
The push refers to repository [localhost:5001/web-dvwa]
deeea3c4d56f: Pushed
585e40f29c46: Pushed
73e92d5f2a6c: Pushed
9713610e6ec4: Pushed
acf8abb873ce: Pushed
97a1040801c3: Pushed
80f9a8427b18: Pushed
a75caa09eb1f: Pushed
latest: digest: sha256:dae203fe11646a86937bf04db0079adef295f426da68a92b40e3b181f337daa7 size: 1997
Step 4. Query your container registry to confirm your container image has been uploaded.
curl https://localhost:${REGISTRY_PORT}/v2/_catalog
You should get output similar to below.
{"repositories":["web-dvwa"]}
Step 5. Save the digest of the container image to an environment variable called DIGEST.
export DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' localhost:${REGISTRY_PORT}/web-dvwa:latest | cut -d":" -f2)
To look at the digest, run the command below.
echo $DIGEST
The output should look similar to below.
dae203fe11646a86937bf04db0079adef295f426da68a92b40e3b181f337daa7
Create an SBOM
Step 1. Create an SBOM for the container image using syft.
syft localhost:${REGISTRY_PORT}/web-dvwa:latest -o json=sbom.json
You should get output similar to below.
✔ Loaded image localhost:5001/web-dvwa:latest
✔ Parsed image sha256:ab0d83586b6e8799bb549ab91914402e47e3bcc7eea0c5cdf43755d56150cc6a
✔ Cataloged contents 738614d5cf55eef7c055a83881c558b6fc9188c6da94956da63132c04afff180
├── ✔ Packages [221 packages]
├── ✔ Executables [1,132 executables]
├── ✔ File metadata [9,458 locations]
└── ✔ File digests [9,458 files]
A newer version of syft is available for download: 1.42.1 (installed version is 1.41.1)
Scan the SBOM
Step 1. Scan the SBOM for Common Vulnerabilities and Exposures (CVEs) using grype.
grype sbom.json -o json=raw-scan.json
You should get output similar to below. Note the number of vulnerability matches (2097 in total).
✔ Vulnerability DB [updated]
✔ Scanned for vulnerabilities [2097 vulnerability matches]
├── by severity: 327 critical, 760 high, 700 medium, 99 low, 210 negligible (1 unknown)
└── by status: 1362 fixed, 735 not-fixed, 0 ignored
Step 2. Review the CVEs reported by grype using jq.
cat raw-scan.json | jq '.vulnerabilities'
Create a VEX Document and Rescan the SBOM
Step 1. Pick a component and a CVE (e.g., CVE-2019-11043) Grype associated with it that you want to suppress. Then, run Grype again, but pipe its output to jq to get the Package URL its using for the component you want to suppress.
grype sbom.json -o json | jq -r '.matches[] | select(.vulnerability.id=="CVE-2019-11043") | .artifact.purl'
You should get output similar to below.
✔ Scanned for vulnerabilities [2097 vulnerability matches]
├── by severity: 327 critical, 760 high, 700 medium, 99 low, 210 negligible (1 unknown)
└── by status: 1362 fixed, 735 not-fixed, 0 ignored
pkg:deb/debian/libapache2-mod-php7.0@7.0.30-0%2Bdeb9u1?arch=amd64&distro=debian-9&upstream=php7.0
pkg:deb/debian/php7.0@7.0.30-0%2Bdeb9u1?arch=all&distro=debian-9
pkg:deb/debian/php7.0-cli@7.0.30-0%2Bdeb9u1?arch=amd64&distro=debian-9&upstream=php7.0
pkg:deb/debian/php7.0-common@7.0.30-0%2Bdeb9u1?arch=amd64&distro=debian-9&upstream=php7.0
pkg:deb/debian/php7.0-gd@7.0.30-0%2Bdeb9u1?arch=amd64&distro=debian-9&upstream=php7.0
pkg:deb/debian/php7.0-json@7.0.30-0%2Bdeb9u1?arch=amd64&distro=debian-9&upstream=php7.0
pkg:deb/debian/php7.0-mysql@7.0.30-0%2Bdeb9u1?arch=amd64&distro=debian-9&upstream=php7.0
pkg:deb/debian/php7.0-opcache@7.0.30-0%2Bdeb9u1?arch=amd64&distro=debian-9&upstream=php7.0
pkg:deb/debian/php7.0-pgsql@7.0.30-0%2Bdeb9u1?arch=amd64&distro=debian-9&upstream=php7.0
pkg:deb/debian/php7.0-readline@7.0.30-0%2Bdeb9u1?arch=amd64&distro=debian-9&upstream=php7.0
pkg:deb/debian/php7.0-xml@7.0.30-0%2Bdeb9u1?arch=amd64&distro=debian-9&upstream=php7.0
Step 2. Create a VEX document using your product PURL, component PURL, and CVE. For products that are containers, the PURL format is pkg:oci/<image-name>@sha256%<image-digest>?tag=<tag>. For example, the product PURL for the latest tag of the web-dvwa container image is pkg:oci/web-dvwa@sha256:${DIGEST}.
vexctl create \
--product="pkg:oci/web-dvwa@sha256:${DIGEST}" \
--subcomponents="pkg:deb/debian/libapache2-mod-php7.0@7.0.30-0%2Bdeb9u1?arch=amd64&distro=debian-9&upstream=php7.0" \
--vuln="CVE-2019-11043" \
--status="not_affected" \
--justification="vulnerable_code_not_in_execute_path" \
--file="vex.json" \
--author="victor@deathlabs.io"
You should get output similar to below.
> VEX document written to vex.json
Step 3. Using the VEX document as additional input, scan the SBOM again to verify the CVE gets suppressed.
grype sbom.json --vex=vex.json -o json=updated-scan.json
You should get output similar to below. As you will see, the number of vulnerability matches went down from 2097 to 2096.
✔ Scanned for vulnerabilities [2096 vulnerability matches]
├── by severity: 327 critical, 760 high, 700 medium, 99 low, 210 negligible (1 unknown)
Step 4. Append the metadata below to the updated-scan.json file created in the previous step and save it as scan.json to ensure the specific attestation follows the vulnerability attestation specification maintained by sigstore.
jq '{
"invocation": {
"parameters": [],
"uri": "https://github.com/anchore/grype",
"event_id": "",
"builder.id": ""
},
"scanner": {
"uri": "pkg:github/anchore/grype@v0.107.0",
"version": .descriptor.version,
"db": {
"uri": "",
"version": .descriptor.db.built
},
"result": .
},
"metadata": {
"scanStartedOn": .descriptor.timestamp,
"scanFinishedOn": .descriptor.timestamp
}
}' updated-scan.json > scan.json
Create Attestations that Link the SBOM and Scan to the Container Image
Step 1. Sign the container image.
cosign sign \
--key cosign.key \
-y \
localhost:${REGISTRY_PORT}/web-dvwa@sha256:${DIGEST}
Step 2. Use cosign to create an attestation in the container registry that links the SBOM (e.g., sbom.json) to the container.image.
cosign attest \
--key cosign.key \
--type cyclonedx \
--predicate sbom.json \
-y \
localhost:${REGISTRY_PORT}/web-dvwa@sha256:${DIGEST}
You should get output similar to below.
Using payload from: sbom.json
The sigstore service, hosted by sigstore a Series of LF Projects, LLC, is provided pursuant to the Hosted Project Tools Terms of Use, available at https://lfprojects.org/policies/hosted-project-tools-terms-of-use/.
Note that if your submission includes personal data associated with this signed artifact, it will be part of an immutable record.
This may include the email address associated with the account with which you authenticate your contractual Agreement.
This information will be used for signing this artifact and will be stored in public transparency logs and cannot be removed later, and is subject to the Immutable Record notice at https://lfprojects.org/policies/hosted-project-tools-immutable-records/.
By typing 'y', you attest that (1) you are not submitting the personal data of any other person; and (2) you understand and agree to the statement and the Agreement terms at the URLs listed above.
tlog entry created with index: 953290970
Step 3. Use cosign to create an attestation in the container registry that links the CVE scan (e.g., scan.json) to the container.
cosign attest \
--key cosign.key \
--type vuln \
--predicate scan.json \
-y \
localhost:${REGISTRY_PORT}/web-dvwa@sha256:${DIGEST}
Step 4. Confirm the attestations exist for the container image in the container registry.
cosign tree localhost:${REGISTRY_PORT}/web-dvwa@sha256:${DIGEST}
You should get output similar to below.
📦 Supply Chain Security Related artifacts for an image: localhost:5001/web-dvwa@sha256:dae203fe11646a86937bf04db0079adef295f426da68a92b40e3b181f337daa7
└── 🔐 Signatures for an image tag: localhost:5001/web-dvwa:sha256-dae203fe11646a86937bf04db0079adef295f426da68a92b40e3b181f337daa7.sig
└── 🍒 sha256:8ede596696d9d6834284b8346a90658761707831e4922bee36eef35af9fa30cd
└── 💾 Attestations for an image tag: localhost:5001/web-dvwa:sha256-dae203fe11646a86937bf04db0079adef295f426da68a92b40e3b181f337daa7.att
├── 🍒 sha256:0284de1ad7f58f3f22d179b225710e1956f017d216b9e3b02b013da961472736
└── 🍒 sha256:0e016e70798e317fdeeab768f24d65c6f87405b0c05344a42f574ab31ed7dab3
Step 4. Verify the SBOM attestation using the public key (cosign.pub) created earlier.
cosign verify-attestation \
--key cosign.pub \
--type cyclonedx \
localhost:${REGISTRY_PORT}/web-dvwa@sha256:${DIGEST}
Step 5. Verify the CVE scan attestation using the public key (cosign.pub) created earlier.
cosign verify-attestation \
--key cosign.pub \
--type vuln \
localhost:${REGISTRY_PORT}/web-dvwa@sha256:${DIGEST}
Kyverno will perform the same verifications when an attempt to deploy the container image is made.
Deploy the Container Image onto the Kubernetes Cluster
Step 1. Try to deploy a workload on your Kubernetes cluster that uses your container image.
kubectl apply -f app.yaml
The output should be similiar to below.
Error from server: error when creating "app.yaml": admission webhook "ivpol.validate.kyverno.svc-fail" denied the request: Policy check-images failed: Images with greater than or equal to 325 critical CVEs are prohibited.
Step 2. Modify the provided kyverno-policy.yml so it accepts more risk (i.e., set it to something greater than 325 critical CVEs). Then, apply the Kyverno policy update.
kubectl apply -f kyverno-policy.yaml
The output should be similiar to below.
imagevalidatingpolicy.policies.kyverno.io/check-images configured
Step 3. Deploy a workload on your Kubernetes cluster that uses your container image.
kubectl apply -f app.yaml
The output should be similiar to below.
pod/demo-pod created
service/demo-service created
Step 4. Check on the health of the workload using the command below.
kubectl describe pod demo-pod
The output should be similiar to below.
Name: demo-pod
Namespace: default
Priority: 0
Service Account: default
Node: demo-cluster-worker2/172.18.0.4
Start Time: Tue, 03 Mar 2026 21:32:01 -0500
Labels: app=dvwa
Annotations: kyverno.io/image-verification-outcomes:
{"check-images":{"name":"check-images","ruleType":"ImageVerify","message":"success","status":"pass"}}
Status: Running
IP: 10.244.1.6
IPs:
IP: 10.244.1.6
Containers:
dvwa:
Container ID: containerd://894f57e33bfe8405618614f9e26367abd84058efdff98a14fa737a35756d7560
Image: demo-registry:5000/web-dvwa:latest
Image ID: demo-registry:5000/web-dvwa@sha256:dae203fe11646a86937bf04db0079adef295f426da68a92b40e3b181f337daa7
Port: <none>
Host Port: <none>
State: Running
Started: Tue, 03 Mar 2026 21:32:02 -0500
Ready: True
Restart Count: 0
Environment: <none>
Mounts:
/var/run/secrets/kubernetes.io/serviceaccount from kube-api-access-5tsx2 (ro)
Conditions:
Type Status
PodReadyToStartContainers True
Initialized True
Ready True
ContainersReady True
PodScheduled True
Volumes:
kube-api-access-5tsx2:
Type: Projected (a volume that contains injected data from multiple sources)
TokenExpirationSeconds: 3607
ConfigMapName: kube-root-ca.crt
Optional: false
DownwardAPI: true
QoS Class: BestEffort
Node-Selectors: <none>
Tolerations: node.kubernetes.io/not-ready:NoExecute op=Exists for 300s
node.kubernetes.io/unreachable:NoExecute op=Exists for 300s
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Scheduled 33s default-scheduler Successfully assigned default/demo-pod to demo-cluster-worker2
Normal Pulling 33s kubelet spec.containers{dvwa}: Pulling image "demo-registry:5000/web-dvwa:latest"
Normal Pulled 33s kubelet spec.containers{dvwa}: Successfully pulled image "demo-registry:5000/web-dvwa:latest" in 86ms (86ms including waiting). Image size: 178370991 bytes.
Normal Created 33s kubelet spec.containers{dvwa}: Created container: dvwa
Normal Started 32s kubelet spec.containers{dvwa}: Started container dvwa
Step 5. Open a browser to http://localhost:30000 and confirm you're able to interact with the workload.
References
Anchore: Open Source | Guides | Vulnerability Scanning | Filter scan results
https://oss.anchore.com/docs/guides/vulnerability/filter-results/#product-identification
Cosign
Cosign stores attestations as OCI artifacts associated with an image digest. Attestations are not part of the image layers.
https://docs.sigstore.dev/cosign/verifying/attestation/
Docker Scout: Create an exception using the VEX
https://docs.docker.com/scout/how-tos/create-exceptions-vex/
kind: Local Registry
https://kind.sigs.k8s.io/docs/user/local-registry/
kind: Using WSL2
https://kind.sigs.k8s.io/docs/user/using-wsl2/#accessing-a-kubernetes-service-running-in-wsl2
Kyverno: Host Mount
https://main.kyverno.io/docs/policy-types/cluster-policy/verify-images/sigstore/#host-mount
OCI Artifacts
Artifacts are stored as OCI manifests and reference a subject manifest via its digest.
https://github.com/opencontainers/artifacts?tab=readme-ov-file
OpenVEX Specification v0.2.0
https://github.com/openvex/spec/blob/main/OPENVEX-SPEC.md#status-justifications
Package-URL (PURL) Specification: OCI Definition
https://github.com/package-url/purl-spec/blob/main/types/oci-definition.json