The Silent Failure: A Common `foreach` Pitfall
In Kubernetes policy enforcement, silent failures represent a significant threat. Kyverno GitHub issue #14007
recently highlighted a critical example: a user's foreach
policy with a deny
condition was successfully validated and accepted by the API server, yet it failed to enforce any of its intended rules. This perplexing "silent failure" created a major, undiscovered security vulnerability. The behavior was not a simple bug but rather exposed a fundamental nuance in Kyverno's processing logic, specifically how context is handled within iterative rule execution.
This article dissects this precise issue to reveal the underlying architectural reasons for the policy's inertness. We will explore why a syntactically valid policy can fail to trigger and, most importantly, provide a clear, authoritative solution. By understanding the mechanics behind this failure, you will learn to write effective, predictable foreach
policies, ensuring your intended denials are always enforced and avoiding this critical pitfall.
Why `foreach` Exists: The Developer Experience Imperative
Kyverno's foreach
construct embodies the design principle of "making the right way the easy way," a philosophy championed by cloud-native thought leader Kelsey Hightower. It simplifies policy authoring by abstracting away complex array manipulations, enabling a natural "for each container, ensure X" mindset. Instead of wrestling with intricate JMESPath queries to iterate over lists like spec.containers[]
, foreach
handles the iteration implicitly.
This approach dramatically improves policy readability and maintainability. By lowering the technical barrier, foreach
makes security and configuration policies more accessible to the entire engineering team, not just a few specialists. The result is a more collaborative and reliable policy-as-code workflow, where intent is clear and enforcement is predictable.
Under the Hood: Sub-Rule Generation and Context Failure
Kyverno processes a foreach
loop by dynamically generating temporary, in-memory sub-rules for each element in the target array. For a policy designed to deny privileged containers, a user wrote the following syntactically valid rule:
rules:
- name: check-privileged-containers
match:
resources: { kinds: [Pod] }
foreach:
- list: "request.object.spec.containers"
deny:
conditions:
any:
- key: "{{ element.securityContext.privileged }}"
operator: Equals
value: true
This policy failed silently due to a subtle breakdown in context propagation. The deny
condition's evaluation logic did not correctly interpret the {{ element }}
context variable supplied by the foreach
iterator. Consequently, the engine could not resolve the dynamic path to each container's securityContext
.
Log analysis from the Kyverno pod confirmed this failure mechanism. For every generated sub-rule—one for each container—the engine attempted to validate the exact same static JSON path: /spec/template/spec/containers/0/securityContext/
. The iteration context was lost, causing the engine to default to the first element's path ([0]
) for all subsequent checks. This rendered the rule inert for any container beyond the first, creating a critical security blind spot. This type of context loss is particularly severe for rule properties that depend on dynamic path resolution, as they cannot locate the correct data for evaluation.

The Definitive Solution: Correct Implementation with `pattern` and CEL
The definitive solution is to use a validate
rule with a pattern
or cel
block, as both are designed to correctly operate within the foreach
context.
The recommended best practice using pattern
is:
rules:
- name: check-privileged-containers-pattern
match:
any:
- resources:
kinds:
- Pod
- Deployment
- StatefulSet
- DaemonSet
- Job
validate:
message: "Privileged containers are not allowed."
foreach:
- list: "request.object.spec.template.spec.containers"
pattern:
securityContext:
privileged: false
This policy works reliably for the following reasons:
list: "request.object.spec.template.spec.containers"
: This JMESPath correctly targets the container list in Pods and workload controllers (like Deployments or StatefulSets) that embed a Pod template. Usingrequest.object
ensures the path is resolved against the complete incoming resource, providing broad applicability.pattern: ...
: Unlike thedeny
condition, thepattern
block is architected to work withforeach
. It implicitly applies its structure against eachelement
passed by the iterator. Kyverno understands that thepattern
must match the structure of an individual container object, avoiding the context-loss failure entirely. The checkprivileged: false
is correctly evaluated for every container in the list.
An equally valid and powerful best practice is to use a validate.cel
rule. Common Expression Language (CEL) provides a concise and efficient way to express the same logic:
rules:
- name: check-privileged-containers-cel
match:
any:
# ... same match block as above ...
validate:
message: "Privileged containers are not allowed."
foreach:
- list: "request.object.spec.template.spec.containers"
cel:
expression: "element.securityContext.privileged == false"
Here, the CEL expression
directly accesses the element
variable provided by the foreach
loop. The expression element.securityContext.privileged == false
is evaluated for each container, providing a clear, explicit, and reliable validation that is functionally identical to the pattern
approach.
Practical Applications and Best Practices
The silent failure of deny
within a foreach
loop underscores a critical lesson: policy constructs must be chosen based on their specific design and context-handling capabilities. This principle extends to other common use cases, such as enforcing a trusted image registry. To achieve this, a single validate
rule can contain two foreach
blocks. The first iterates over request.object.spec.template.spec.containers
and the second over request.object.spec.template.spec.initContainers
. Within each, a pattern
block with image: "my-registry.io/*"
ensures every container, regardless of its type, uses an approved image source. This approach is robust and avoids the context-loss pitfalls seen with deny
.
By internalizing the mechanics of foreach
processing, you can write policies that are clear, maintainable, and—most importantly—effective. To build resilient and predictable policies, adhere to these critical best practices:
- Always use
validate
with apattern
orcel
block for conditional logic inside aforeach
loop. These constructs are architected to correctly receive theelement
context from the iterator, whereasdeny
conditions can fail silently. - Use
request.object
in thelist
path (e.g.,request.object.spec.template.spec.containers
) to ensure your policy is compatible with both standalone resources (like Pods) and workload controllers (like Deployments) that use pod templates. - Test policies against multiple, distinct resource kinds (e.g., Pod, Deployment, StatefulSet) to verify that JMESPath expressions resolve correctly and the rule behaves as intended across all relevant targets.
- When debugging, remember that Kyverno generates temporary sub-rules for each item in the list. Check Kyverno pod logs for the evaluation status of each individual sub-rule to pinpoint context-loss failures or other iteration-specific issues.