Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français
Lire en français

From SLSA provenance to decision: blocking non-compliant artifacts

By updated

Improve this page

An SLSA provenance is a signed attestation that states where, from which source and by which builder an artifact was built. It protects nothing as long as nobody verifies it: security comes from the decision that refuses a non-compliant artifact, in CI then at Kubernetes admission. This lesson is for a DevOps engineer who already produces attestations and wants them to really block.

Everything is measured on 1 October 2026 on public artifacts: slsa-verifier 2.7.1 accepts the kyverno:v1.19.1 image designated by digest and refuses it by its tag ("the image is mutable"), a Kyverno 1.19.1 policy replayed on kind admits that image and refuses a provenance from another repository, and a static pod slips past admission without being blocked.

What you will learn

  • Read what a provenance proves, and which SLSA Build level it reaches.
  • Verify a provenance in CI with slsa-verifier, gh attestation verify or cosign verify-attestation, constraining source and builder.
  • Block at Kubernetes admission an image without compliant provenance.
  • Spot the path that bypasses admission: the static pod.
  • Control a break-glass exception, disabled by default in Kyverno.
  • Produce the evidence for SOCLE-REL-PRV-1 (in French), a provenance describing builder, source and parameters, and for SOCLE-REL-VER-1, its verification before use.

What does an SLSA provenance prove?

SLSA (Supply-chain Levels for Software Artifacts) describes levels of assurance. Its version 1.2 sorts them into two tracks: Build, from L0 to L3, and Source, from L1 to L4. A provenance belongs to the Build track: it names the builder, the source repository, the reference (tag, commit) and the build parameters, all signed.

The level depends on who produces the provenance. An attestation issued by the project's own workflow, such as actions/attest-build-provenance, reaches Build L2: the platform is hosted and the provenance is signed, but the builder is the workflow the project controls. The isolated generators of slsa-github-generator aim for Build L3, because the provenance is produced by a reusable workflow the project cannot modify.

Measured provenanceRecorded builderTargeted level
gh_2.102.0_linux_amd64.tar.gz (GitHub CLI)https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunkBuild L2
kyverno:v1.19.1.../slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v2.1.0Build L3

The first row shows it: the builder of a GitHub attestation is the repository's workflow, which anyone who can push to it could modify. The second is a third-party, isolated workflow. That difference also changes the identity to verify, and it is the main trap of this lesson.

When should provenance be verified?

Verification applies at two complementary checkpoints. The diagram places them on the artifact's path, with the route that escapes the second, the static pod:

An artifact's path: the build produces the image and its provenance, a CI check with slsa-verifier refuses it or lets it through to the registry's release tag, then Kyverno admission refuses it or creates the pod; a static pod, outside the API, reaches the pod-created state without going through admission.Buildimage and provenanceCI checkslsa-verifierRegistryrelease tagAdmissionKyvernoPod createdRefusal and reportStatic podoutside the API
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %
CheckpointGoalTools
CI pipelinePrevent the promotion of an artifact without valid provenanceslsa-verifier, gh, cosign
Kubernetes admissionBlock the deployment of a non-compliant imageKyverno, Sigstore Policy Controller

Verification in the CI pipeline

The pipeline is the first place to refuse an artifact, because it knows the context: source repository, expected tag, produced digest. Three tools cover this need depending on how the provenance was produced. In every case, verification comes before the image's promotion to a release tag: publishing first and finding out afterwards protects nobody.

With slsa-verifier

slsa-verifier, from the SLSA project, verifies provenances produced by the slsa-github-generator generators. It checks the signature, the Rekor entry, the builder, then compares source and tag with those you give it. Measured on its own release binary and on the Kyverno image:

Terminal window
slsa-verifier verify-artifact slsa-verifier-linux-amd64 \
--provenance-path slsa-verifier-linux-amd64.intoto.jsonl \
--source-uri github.com/slsa-framework/slsa-verifier --source-tag v2.7.1
# Verified build using builder ".../builder_go_slsa3.yml@refs/tags/v2.0.0" at commit ea584f45...
# PASSED: SLSA verification passed
slsa-verifier verify-image \
ghcr.io/kyverno/kyverno:v1.19.1@sha256:b31d8511ae5fd6010e2a01ea72ebae08eb82fa51d91af14a1d9fe989949b4edb \
--source-uri github.com/kyverno/kyverno --source-tag v1.19.1
# Verified build using builder ".../generator_container_slsa3.yml@refs/tags/v2.1.0" at commit 40ec788d...
# PASSED: SLSA verification passed

Failures are as telling as successes, and they all exit with code 1:

Measured attemptMessage
--source-uri github.com/attaquant/slsa-verifierexpected source 'attaquant/slsa-verifier', got 'slsa-framework/slsa-verifier'
--source-tag v2.7.0 instead of v2.7.1expected tag 'v2.7.0', got 'v2.7.1'
image designated by :v1.19.1, without digestthe image is mutable: 'ghcr.io/kyverno/kyverno:v1.19.1'

The last row matters: slsa-verifier verify-image refuses a tag. An example that verifies "$IMAGE:$TAG" therefore does not work; you must pass the digest produced by the build.

.github/workflows/verify-provenance.yml
name: verify-provenance
on:
workflow_dispatch:
inputs:
digest:
description: "Digest of the image to promote (sha256:...)"
required: true
type: string
tag:
description: "Git tag the image must come from (v1.4.0)"
required: true
type: string
permissions: {}
jobs:
verify:
runs-on: ubuntu-24.04
permissions: {}
steps:
- name: Install slsa-verifier v2.7.1, hash checked
run: |
curl -sSfLo slsa-verifier https://github.com/slsa-framework/slsa-verifier/releases/download/v2.7.1/slsa-verifier-linux-amd64
echo "946dbec729094195e88ef78e1734324a27869f03e2c6bd2f61cbc06bd5350339 slsa-verifier" | sha256sum -c -
sudo install -m 0755 slsa-verifier /usr/local/bin/slsa-verifier
- name: Verify provenance before any promotion
env:
IMAGE: ghcr.io/${{ github.repository }}@${{ inputs.digest }}
SOURCE: github.com/${{ github.repository }}
TAG: ${{ inputs.tag }}
run: |
slsa-verifier verify-image "${IMAGE}" \
--source-uri "${SOURCE}" \
--source-tag "${TAG}"

The GitHub workflow passes actionlint 1.7.12, zizmor 1.30.0 and poutine 1.1.6 without findings; inputs go through env:, never straight into the script. The binary is installed by a hash-checked download rather than through the slsa-verifier/actions/installer action, which poutine flags as coming from an unverified publisher. The GitLab job, which verifies an upstream image before copying it internally, was replayed as is in an alpine:3.22 container: slsa-verifier: OK, then PASSED. plumber could not be run on it, for lack of a test GitLab instance.

With gh attestation verify

GitHub attestations (actions/attest-build-provenance) are verified with the gh CLI. It queries GitHub's attestations API, not the registry. Measured with gh 2.100.0 on the release archive of the GitHub CLI itself:

Terminal window
gh attestation verify gh_2.102.0_linux_amd64.tar.gz --repo cli/cli \
--signer-workflow cli/cli/.github/workflows/deployment.yml
echo $?
# 0
gh attestation verify gh_2.102.0_linux_amd64.tar.gz --repo cli/cli \
--signer-workflow cli/cli/.github/workflows/autre.yml
# Error: verifying with issuer "sigstore.dev" (code 1)

Without --signer-workflow, verification accepts any repository workflow, a consequence of the Build L2 level described above: the option is therefore essential, since it restricts the provenance to the single expected release workflow. A missing attestation returns HTTP 404 from the API, measured on the slsa-verifier binary, whose provenance goes through the generator and not through GitHub attestations.

With cosign verify-attestation

cosign also verifies a provenance attached to the image, with --type slsaprovenance. The identity to constrain is that of the provenance signer, and that is where examples often go wrong: for an image produced by slsa-github-generator, the signer is the generator, not the project's workflow.

Terminal window
cosign verify-attestation --type slsaprovenance \
--certificate-identity-regexp '^https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/kyverno/kyverno:v1.19.1@sha256:b31d8511ae5fd6010e2a01ea72ebae08eb82fa51d91af14a1d9fe989949b4edb
# Certificate subject: https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v2.1.0
# GitHub Workflow Repository: kyverno/kyverno
# GitHub Workflow Ref: refs/tags/v1.19.1

The output, measured with Cosign v3.1.3, gives the certificate subject (the generator) and the repository that called it (kyverno/kyverno). The decoded provenance carries the predicateType https://slsa.dev/provenance/v0.2 and the source git+https://github.com/kyverno/kyverno@refs/tags/v1.19.1. An identity pattern that is too broad, such as .*, cancels the verification.

Verification at Kubernetes deployment

The CI check only covers images that went through the official pipeline. A manual kubectl apply or a parallel pipeline bypasses it. The admission controller closes that door: it intercepts every Pod creation request before it is stored in etcd, and refuses those whose image has no valid provenance. There is, however, a path that does not go through the API, covered in "Can an admission controller be bypassed".

Kyverno ImageValidatingPolicy

Kyverno 1.19 verifies attestations with an ImageValidatingPolicy. The following policy was replayed on a disposable kind cluster (Kubernetes 1.37): it requires a provenance signed by the SLSA generator, then checks in its content that the source is a tag of the expected repository.

kyverno-slsa-policy.yaml
apiVersion: policies.kyverno.io/v1
kind: ImageValidatingPolicy
metadata:
name: verify-slsa-provenance
spec:
validationActions: [Deny]
webhookConfiguration:
timeoutSeconds: 30
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
matchImageReferences:
- glob: "ghcr.io/kyverno/*"
attestors:
- name: generateur
cosign:
keyless:
identities:
- issuer: "https://token.actions.githubusercontent.com"
subjectRegExp: "^https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v"
ctlog:
url: "https://rekor.sigstore.dev"
attestations:
- name: provenance
intoto:
type: https://slsa.dev/provenance/v0.2
validations:
- expression: >-
images.containers.map(image, verifyAttestationSignatures(image, attestations.provenance, [attestors.generateur])).all(e, e > 0)
message: "Aucune provenance SLSA signée par le générateur slsa-github-generator."
- expression: >-
images.containers.map(image, extractPayload(image, attestations.provenance).predicate.invocation.configSource.uri.startsWith('git+https://github.com/kyverno/kyverno@refs/tags/')).all(e, e)
message: "La provenance ne désigne pas un tag du dépôt attendu."

Four attempts, made with the same kyverno:v1.19.1 image designated by digest, show what each part of the policy brings:

Policy variantMeasured result
Policy abovepod/kyverno-avec-provenance created
Expected source github.com/attaquant/kyvernoRefused: La provenance ne désigne pas un tag du dépôt attendu.
Expected identity ^https://github.com/kyverno/.*/\.github/workflows/Refused: Aucune provenance SLSA signée par le générateur slsa-github-generator.
Path extractPayload(...).invocation without .predicateRefused: failed to evaluate policy: no such key: invocation

The third row is the classic trap: a policy that expects the project's workflow as signer refuses a perfectly valid provenance, since the generator is the one that signs. The fourth is a reminder that extractPayload() returns the whole in-toto statement, whose provenance is the predicate field. And, as with signatures, a validation reduced to expression: "true" checks nothing and blocks everything.

Progressive rollout of the policy

Switching straight to blocking on an existing cluster breaks the deployments of every team that has no provenance yet. The four steps below vary only two settings: validationActions, which decides between observing and refusing, and matchConditions, which narrows the scope to the chosen namespaces.

  1. Audit mode: observe without blocking.

    spec:
    validationActions: [Audit]

    Violations accumulate in the PolicyReport objects for one to two weeks.

  2. Analyse the violations.

    Terminal window
    kubectl get policyreport -A -o json | jq '.items[].results[] | select(.result == "fail")'
  3. Deny mode on staging, with a matchCondition targeting the namespace.

    spec:
    validationActions: [Deny]
    matchConditions:
    - name: staging-only
    expression: "object.metadata.namespace == 'staging'"
  4. Progressive extension to production.

    spec:
    matchConditions:
    - name: staging-and-prod
    expression: "object.metadata.namespace in ['staging', 'production']"

Can an admission controller be bypassed?

Yes, through a documented Kubernetes mechanism: the static pod. The kubelet on each node watches a local directory, /etc/kubernetes/manifests by default, and starts any Pod it finds there without consulting the Kubernetes API. No admission request is issued, so no Kyverno policy or ValidatingAdmissionPolicy has a say. Provenance verification is not bypassed by a trick: it is never called. The exact path appears in the kubelet configuration, under the staticPodPath key.

This behaviour is not a defect, it is what lets the control plane start. On a cluster installed with kubeadm, etcd, kube-apiserver, kube-controller-manager and kube-scheduler are themselves static pods: requiring validation by the API server to start the API server would be circular.

Why the Pod still ends up showing in kubectl

Once the container has started, the kubelet creates a mirror pod in the API, a read-only copy meant for visibility. That mirror pod does go through admission, and that is where things become misleading: the verdict applies to the copy, never to the container, which is already running. The table gives the two possible outcomes for a manifest whose image violates the policy.

Policy engineVerdict on the mirror podWhat kubectl get pods showsContainer on the node
ValidatingAdmissionPolicy (Kubernetes 1.35, earlier measurement)RefusedNo Pod, the namespace looks emptyRunning
Kyverno 1.19.1 (kind, Kubernetes 1.37, replayed for this lesson)Acceptedstatique-verif-m06-control-plane, Running, source fileRunning

Both outcomes mislead the operator, in opposite directions. With the native policy, the refusal only appears in the kubelet logs, as Failed creating a mirror pod, while the container serves traffic. With Kyverno, the Pod shows like any other and only the PolicyReport carries the verdict: measured, a FAIL 1 row for statique-verif-m06-control-plane. In both cases, kubectl get pods does not describe what is really running.

How do you spot a Pod that did not go through the API?

The kubelet marks these Pods with an annotation, kubernetes.io/config.source, which is file for a static pod and api for an ordinary Pod. This command lists the Pods concerned across the cluster:

Terminal window
kubectl get pods -A \
-o custom-columns='NAMESPACE:.metadata.namespace,NOM:.metadata.name,SOURCE:.metadata.annotations.kubernetes\.io/config\.source,NOEUD:.spec.nodeName' \
| grep -v '<none>'

The output normally contains the control plane components, and nothing else. Any file row outside kube-system, or on a worker node, deserves immediate inspection. This check queries the API: it will therefore not see a static pod whose mirror pod was refused. The exhaustive list of what runs is read on the node, with crictl pods.

Managing exceptions (break-glass)

Even the best policy needs an exception mechanism for emergencies. The goal is to allow the bypass without normalising it: a narrow, traced, approved exception, deleted after use.

A PolicyException for an ImageValidatingPolicy

A PolicyException designates the target policy through policyRefs and the exempted scope through CEL matchConditions. Two facts measured on Kyverno 1.19.1 shape everything else. Exceptions are disabled by default: the chart sets the --enablePolicyException=false argument, and kubectl apply warns "PolicyException resources would not be processed until it is enabled". And Kyverno does not expire an exception: a date annotation is purely informative.

breakglass-inc-12345.yaml
apiVersion: policies.kyverno.io/v1
kind: PolicyException
metadata:
name: breakglass-inc-12345
namespace: default
labels:
exception-type: break-glass
incident-id: "INC-12345"
annotations:
expiry: "2026-10-02T12:00:00Z" # informative: Kyverno deletes nothing
reason: "Correctif urgent, provenance ajoutée après l'incident"
spec:
policyRefs:
- name: verify-slsa-provenance
kind: ImageValidatingPolicy
matchConditions:
- name: pods-hotfix-uniquement
expression: "object.metadata.namespace == 'default' && object.metadata.name.startsWith('hotfix-')"

Enabled through features.policyExceptions.enabled=true and features.policyExceptions.namespace=default in Helm chart 3.9.1, exceptions apply: measured, hotfix-api is created, autre-api stays refused, and Kyverno's metric counts the former with result="skip". Restricting namespace to a single exceptions namespace prevents a team from creating its own anywhere.

Automated exception workflow

Creating the exception by hand in the middle of an on-call shift produces an object that is too broad and untraced. The workflow below enforces a form, a protected environment that requires approval, validation of the inputs before writing them into the manifest, and a notification to the security channel. The cluster access step depends on your platform and is not shown.

.github/workflows/breakglass.yml
name: breakglass
on:
workflow_dispatch:
inputs:
incident_id:
description: "Incident identifier (INC-12345)"
required: true
type: string
prefixe_pod:
description: "Prefix of exempted pods (hotfix-)"
required: true
type: string
raison:
description: "Reason for the exception"
required: true
type: string
permissions: {}
jobs:
exception:
runs-on: ubuntu-24.04
environment: production-breakglass # approval required
permissions:
contents: read
steps:
# Cluster access step specific to your platform (not shown)
- name: Create the PolicyException
env:
INCIDENT_ID: ${{ inputs.incident_id }}
PREFIXE: ${{ inputs.prefixe_pod }}
RAISON: ${{ inputs.raison }}
ACTEUR: ${{ github.actor }}
run: |
[[ "${INCIDENT_ID}" =~ ^INC-[0-9]+$ ]] || { echo "incident_id invalide"; exit 1; }
[[ "${PREFIXE}" =~ ^[a-z0-9-]+-$ ]] || { echo "prefixe_pod invalide"; exit 1; }
cat > exception.yaml <<EOF
apiVersion: policies.kyverno.io/v1
kind: PolicyException
metadata:
name: breakglass-${INCIDENT_ID,,}
namespace: production
labels:
exception-type: break-glass
incident-id: "${INCIDENT_ID}"
annotations:
created-by: "${ACTEUR}"
reason: "${RAISON//\"/}"
spec:
policyRefs:
- name: verify-slsa-provenance
kind: ImageValidatingPolicy
matchConditions:
- name: prefixe-autorise
expression: "object.metadata.name.startsWith('${PREFIXE}')"
EOF
kubectl apply -f exception.yaml
- name: Notify the security team
uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
with:
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
payload: |
channel: ${{ secrets.SLACK_SECURITY_CHANNEL_ID }}
text: "Exception break-glass ${{ inputs.incident_id }} créée par ${{ github.actor }}"

This workflow passes actionlint, zizmor and poutine without findings. Inputs go through env: and are validated by regular expression before entering the manifest: the previous version, which injected ${{ inputs.reason }} directly into the script, offered a command injection to anyone able to trigger the workflow. The notification assumes two secrets, SLACK_BOT_TOKEN (chat:write scope) and SLACK_SECURITY_CHANNEL_ID.

Recording and auditing decisions

Every verification decision must leave an exploitable trace, for compliance as well as diagnosis. Kyverno produces two: the PolicyReport objects, in the cluster, and Prometheus metrics. PolicyReport objects follow the life cycle of resources: a deleted Pod takes its report with it, hence the value of exporting them to external storage, for instance with the Kyverno project's Policy Reporter tool.

Verification metrics

Metric names are not those of the old ClusterPolicy. Measured on the kyverno-svc-metrics service of Kyverno 1.19.1, those that concern an ImageValidatingPolicy are the following; the kyverno_policy_exception_total metric, quoted in some examples, is not among them:

MetricWhat it countsAlert if
kyverno_image_validating_policy_results_total{result="fail"}Refusals per policy and namespaceSudden spike
kyverno_image_validating_policy_results_total{result="skip"}Passes under exceptionNon-zero outside an incident
kyverno_image_validating_policy_execution_duration_secondsVerification durationClose to the webhook timeout
kyverno_admission_review_duration_secondsAdmission response timeSeveral seconds

Decision dashboard

Three queries are enough for a useful dashboard: the refusal rate per policy, the breakdown by result and namespace, and the volume of passes under exception. The last one is the most telling in review: if it never goes back to zero, exceptions have become permanent and the policy no longer protects anything.

PromQL queries
# Refusals per policy
sum by (policy_name) (rate(kyverno_image_validating_policy_results_total{result="fail"}[1h]))
# Results per namespace
sum by (resource_namespace, result) (rate(kyverno_image_validating_policy_results_total[24h]))
# Passes under exception
sum(increase(kyverno_image_validating_policy_results_total{result="skip"}[24h]))

Decision matrix per environment

The same policy is not tuned the same way in every environment: the cost of an unjustified block is zero in dev and high in production. This matrix gives a starting point, to be read as a contract between the platform team and the application teams. The Notification column matters as much as the Mode column: a silent refusal turns into an incident ticket within the hour.

EnvironmentModeExceptionsNotification
devAuditAll allowedLog only
stagingDenyThrough a reviewed PRDeployment channel
productionStrict DenyBreak-glass and security approvalOn-call and SIEM

Common troubleshooting

Three symptoms cover most calls after switching to blocking. The first comes from the artifact, the second from the network, the third from the Kyverno installation. Work out which one before touching the policy.

Image refused: attestation not found or different identity

Before suspecting the policy, check what the registry exposes for the targeted digest, and who signed the attestation. cosign tree lists the attached attestations; cosign verify-attestation with a wrong identity shows the actual identity after got.

Terminal window
cosign tree ghcr.io/kyverno/kyverno:v1.19.1@sha256:b31d8511ae5fd6010e2a01ea72ebae08eb82fa51d91af14a1d9fe989949b4edb
# └── Attestations for an image tag: ghcr.io/kyverno/kyverno:sha256-b31d8511....att

Common causes: the pipeline produced no provenance, the policy expects the project's workflow while the generator signs, or the attestation covers a different digest from the deployed image.

Verification timeout exceeded

Verification queries the registry and the transparency log during admission, on the critical path of Pod creation. The timeout is set in the policy, under webhookConfiguration.timeoutSeconds, and Kubernetes caps it at 30 seconds for an admission webhook: a value of 60, still seen in examples, is not accepted.

spec:
webhookConfiguration:
timeoutSeconds: 30

Common causes: slow Rekor or registry, cluster egress to the Internet filtered by a proxy.

Policy has no effect

A policy that lets everything through almost always signals that the webhook is no longer registered, that the admission controller is not responding, or that the images do not match the glob pattern of matchImageReferences. These three commands check Kyverno's Pods, the webhook registration, then the controller logs.

Terminal window
kubectl get pods -n kyverno
kubectl get validatingwebhookconfigurations | grep kyverno
kubectl logs -n kyverno -l app.kubernetes.io/component=admission-controller --tail=100

Key points

  • A provenance protects nothing without a decision: you verify in CI and at admission.
  • SLSA 1.2 distinguishes Build L0-L3 and Source L1-L4; a GitHub attestation alone reaches Build L2, an isolated generator aims for Build L3.
  • slsa-verifier verify-image requires a digest: a tag is refused as mutable.
  • The signer of an slsa-github-generator provenance is the generator, not the project's workflow: the policy must expect it.
  • With Kyverno, the validation calls verifyAttestationSignatures(), and extractPayload() returns the whole in-toto statement (.predicate).
  • A static pod starts without admission: watch kubernetes.io/config.source: file outside kube-system.
  • PolicyException objects are disabled by default and never expire on their own.
  • The evidence expected by SOCLE-REL-PRV-1 is a provenance describing builder, source and parameters; SOCLE-REL-VER-1 expects its verification before use.

Knowledge check

Check that the essentials of this lesson are understood. The questions only cover what has just been explained here.

Knowledge check

Check what you have learned with this interactive quiz

6 questions
6 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn