Enforce Supply Chain Security Using Attestations and Policy-as-Code

Ingredients

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

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