How to document policy exceptions as audit evidence

An exception becomes audit evidence when the cluster can show which policy and resources it covers, that Kyverno applied it, who approved it and when it expires. Kyverno’s PolicyException records the first two and the PolicyReport records the third. Approval and expiry have no field in the Kyverno docs, so store them as annotations beside the manifest.
Checked against the Kyverno v1.19 docs as of 2026-10-27.
What an auditor will ask for
Auditors tend to ask the same five questions about an exception. Each answer needs a home, either in the cluster or in your change record. The table shows where each one lives and whether Kyverno’s own docs define it.
| Question | Where the answer lives | Defined in Kyverno docs? |
|---|---|---|
| Which policy is excepted? | spec.policyRefs, the policy name and kind |
Yes |
| Which resources are covered? | spec.matchConditions, a CEL expression |
Yes |
| Was the exception applied? | A PolicyReport result with result: skip and properties.exceptions |
Yes |
| Who approved it? | An annotation on the object, plus the ticket or pull request | No |
| When does it expire? | An annotation, plus a scheduled review | No |
The last two rows are gaps in the product, not in your process. Fill them with annotations and an approval record, and say so in the evidence pack rather than implying that Kyverno tracks them.
Turn exceptions on before you write one
PolicyExceptions are off until you enable them. Two container flags control them, and the flags reference lists the defaults.
- Set
enablePolicyExceptiontotrue. Its default isfalse. - Set
exceptionNamespaceto the namespace where exceptions may live, such asdefaultin the example below. - Leave
policyReportson. Its default istrue, and it enables the Policy Reports system.
Keeping every exception in one namespace gives the auditor one place to look. The reference is at https://kyverno.io/docs/installation/customization/.
Write the exception as a manifest
Kyverno’s example uses a ValidatingPolicy called require-prod-label and a PolicyException that skips one Deployment by name. The block below keeps that shape and adds three annotations. The annotation keys are our team convention, not Kyverno fields, so Kyverno does not define or check them and the record depends on your process.
apiVersion: policies.kyverno.io/v1
kind: PolicyException
metadata:
name: exclude-skipped-deployment
namespace: default
annotations:
audit.example.com/approved-by: "security-lead"
audit.example.com/expires: "2027-01-31"
audit.example.com/ticket: "SEC-1234"
spec:
policyRefs:
- name: require-prod-label
kind: ValidatingPolicy
matchConditions:
- name: skip-by-name
expression: "object.metadata.name == 'skipped-deployment'"
The docs’ examples use policies.kyverno.io/v1. The docs say it works with CEL-based policy types, including ValidatingPolicy and ImageValidatingPolicy. The spec.policyRefs field names the policy and kind being excluded, and spec.matchConditions narrows the exception to matching resources. In this example, only the Deployment named skipped-deployment in the default namespace is excepted, so keep default in exceptionNamespace.
Show the exception in the policy report
When the match condition is true, Kyverno writes the decision to a PolicyReport. The docs show this result for an exception that fired during a live admission request.
apiVersion: wgpolicyk8s.io/v1alpha2
kind: PolicyReport
metadata:
namespace: default
labels:
app.kubernetes.io/managed-by: kyverno
ownerReferences:
- apiVersion: apps/v1
kind: Deployment
name: skipped-deployment
results:
- policy: vpol-report-background-sample
rule: exception
result: skip
message: 'rule is skipped due to policy exception: default/exclude-skipped-deployment'
properties:
exceptions: exclude-skipped-deployment
process: admission review
source: KyvernoValidatingPolicy
scored: true
scope:
apiVersion: apps/v1
kind: Deployment
name: skipped-deployment
namespace: default
summary:
pass: 0
fail: 0
warn: 0
error: 0
skip: 1
Three fields carry the evidence. result: skip means the rule was not applied. properties.exceptions names the PolicyException that caused it, which is the link back to your approval record. properties.process says whether the decision came from an admission review or a background scan.
Where the record lives and who can read it
The exception is a cluster object, and the proof is a report object. Keep both in places that someone outside the team can reach.
- Keep the PolicyException manifest in Git, and make every change through a pull request that names the approver.
- List the reports with
kubectl get policyreport -A. Runkubectl get polr -o widefor the pass, fail and skip counts, andkubectl get reports -A -o yamlto export the full results. - Give auditors read access. Kyverno’s install creates a ClusterRole named
kyverno:rbac:admin:policyreports. The docs show a RoleBinding that grants it to a user or group in one namespace.
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: admin-policyreports
namespace: default
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: kyverno:rbac:admin:policyreports
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: Group
name: auditors
Be careful with step 3. The docs say that ClusterRole can perform all operations on policy reports, so binding it to auditors also lets them change reports. The docs I checked don’t provide a read-only role, so write one before you hand out access.
Keep the history the reports drop
Policy reports show the cluster as it is now. The docs say reports “do not record historical information,” and that when a resource is deleted, its entry is removed from the report. Blocked requests don’t appear either. For those, the docs point to the policy rule execution metric and to Kubernetes Events.
| Record | What it keeps | What the docs say it misses |
|---|---|---|
| PolicyReport | Current results for existing resources | Deleted resources, blocked requests, Pod/exec |
| Policy rule execution metric or Kubernetes Events | Blocked admission decisions | Retention is not described in the docs I checked |
| Git history of the PolicyException | Every approved change and who made it | Only covers exceptions, not results |
Export reports on a schedule into dated files, capture the metric or Events in the same export, and keep the ticket ID in the annotation so each row links back to its approval. The auditor then sees both the state and the history.
Audit mode and enforce mode record different things
The effect of an exception depends on the policy’s action. Kyverno’s ClusterPolicy and ValidatingPolicy use different fields for the same idea.
| Without an exception | ClusterPolicy (failureAction) |
ValidatingPolicy (validationActions) |
|---|---|---|
Audit: request allowed, violation recorded as fail |
Audit |
Audit |
Enforce: request blocked, only pass results reported |
Enforce |
Deny |
With a matching exception: request allowed, skip recorded |
Both | Both |
In audit mode, the report is the evidence that the violation existed. In enforce mode, the exception is the evidence that someone chose to let a blocked resource in, so its approval matters more.
If you still run ClusterPolicy
The docs also deprecate spec.validationFailureAction in favour of spec.rules[*].validate[*].failureAction. If your exceptions depend on objects in the kyverno.io API group, check the Kyverno docs for the migration path before you update your evidence pack. Our CEL migration post covers the policy side of that move.
Enforcing this with Kyverno
Kyverno supplies the exception object, the skip result and the report. The approval and expiry process around them is yours to run. A workable routine:
- Enable exceptions and set one
exceptionNamespace. - Add each exception through a pull request that carries the approver, ticket and expiry annotations.
- Export PolicyReports, and the blocked-request metric or Events, on a fixed schedule.
- At each review, check every
skipentry against an open exception, and remove exceptions past their expiry.
For the wider compliance program, see continuous compliance. For the audit trail that sits next to exceptions, read SOC 2 controls for Kubernetes you can automate. Earlier Nirmata posts cover the exception workflow itself: temporary policy exceptions with Kyverno, streamlining policy exception management and multi-cluster policy exception management.
If you want the product that sits on top of Kyverno, see the Nirmata Control Hub page. This post makes no product claims about it, and the steps above need only Kyverno.
Frequently asked questions
Can a Kyverno PolicyException show who approved it?
Not by itself. The Kyverno docs define no approver field on a PolicyException. Put the approver and the ticket ID in annotations on the object, and keep the approval itself in the ticket or the pull request that added the manifest. An auditor can then match the exception name in a PolicyReport to that approval, and the two records point at each other.
How do I show an auditor that an exception was actually used?
Look for a PolicyReport result with result set to skip and properties.exceptions naming the PolicyException. Kyverno records that result when the exception matches a live admission request.
Does Kyverno remove a PolicyException when it expires?
The PolicyException docs describe no expiry field and no automatic removal. Store the expiry date as an annotation, then run a scheduled review that lists exceptions past their date and deletes or renews them. Keep the review's output with the reports, so the auditor sees the exception was closed on time.
Why are blocked deployments missing from the policy report?
Policy reports describe resources that exist in the cluster. The docs say resources blocked during admission do not appear in a report, and point to the policy rule execution metric or Kubernetes Events for those decisions. Export those signals on the same schedule as the reports, or the blocked requests have no record at all.