skip to content

Why does a Kyverno deny condition break when the JMESPath field it reads is absent?

level: seniorimportance: should knowfreq 42%

answer

  1. substitution runs before the operator
  2. absent is not false
  3. give the expression a default
  4. the JMESPath or-operator
  5. domain-prefixed keys need quoting

basics

~20 s

Kyverno substitutes the JMESPath expression into the condition before evaluating it. An absent field does not resolve to false; it fails substitution, so the rule errors instead of denying. Supply a default with the JMESPath or-operator, as in field || ''.

solid answer

~50 s

A condition's `key` is a JMESPath expression in `{{ }}` that Kyverno resolves against the admission request *before* the operator runs. If the path is missing — a Service with no `annotations` map, an optional `spec` field nobody set — there is nothing to substitute, so the rule errors rather than quietly evaluating to false. That matters most in a deny block written under `all`, because the condition you thought would be false was the one keeping the rule from firing. The fix is to make the expression total: give it a default with the JMESPath or-operator, `{{ request.object.metadata.annotations."net.example.com/internal-only" || '' }}`, so an absent annotation resolves to an empty string the operator can compare. Two related traps: a key containing dots or slashes must be double-quoted inside the JMESPath, and an expression that projects over a list returns a list, so it needs `AnyIn`/`AllIn` rather than `Equals`.

code

yaml · 12 lines
yaml
validate:
  message: "LoadBalancer Services must be internal-only."
  deny:
    conditions:
      all:
      - key: "{{ request.object.spec.type }}"
        operator: Equals
        value: LoadBalancer
      # no default: an absent annotations map fails substitution
      - key: "{{ request.object.metadata.annotations.\"net.example.com/internal-only\" }}"
        operator: NotEquals
        value: "true"

go deeper

for a junior

Know that the braces hold a JMESPath expression resolved from the admission request, and that a field which is not there is not the same as a field set to false.

for a middle

Be able to write the total form of the expression with an or-default, and to explain why an annotation key containing dots and a slash has to be double-quoted inside the path.

for a senior

Demonstrate the diagnosis: policy report shows a rule error rather than your message, the failing input is the resource with the field missing, and the fix is chosen fail-closed rather than whichever default makes the error go away.

for a principal

Argue for the practice that prevents the whole class: fixtures built from absent fields, an erroring rule treated as an outage of that control, and a review norm that a security rule's default must be the blocking one.

## Substitution happens before evaluation A Kyverno condition looks like a single unit, but it runs in two steps. First the `{{ ... }}` expression in `key` (and in `value`, if it contains one) is resolved as JMESPath against the admission request and substituted in. Only then does the operator compare the substituted key with the value. Everything in this question follows from that ordering. The consequence people are caught by: **an absent field is not false.** In a language where a missing lookup yields a falsy value, `annotations."x" != "true"` would be true for a Service with no annotations, and the rule would fire. Here there is nothing to substitute, and instead of a clean false you get a substitution failure and a rule that errors out. You do not get your `validate.message`; you get a policy error, visible in the policy report for the resource and in the controller's logs. ### The concrete case The rule is: a Service of type LoadBalancer must carry an internal-only annotation, so nobody accidentally publishes an internet-facing endpoint. Written as two conditions under `all` — type is LoadBalancer, and the annotation is not `"true"` — it works beautifully against the resource you tested with, because that resource *had* the annotation set to `false`. The dangerous input is the Service that has no `annotations` map at all, which is most of them. That is precisely the resource the control exists to catch, and it is the one the expression cannot resolve. ### Making the expression total JMESPath's or-operator returns the right-hand side when the left resolves to null or false, so you write the default into the expression itself: ``` key: '{{ request.object.metadata.annotations."net.example.com/internal-only" || '''' }}' ``` Now an absent annotation resolves to an empty string, the `NotEquals "true"` comparison is true, and the deny fires as intended. Pick the default deliberately: it decides what an unset field *means* to your rule. Defaulting to `''` in a rule that denies on "not the required value" is fail-closed and usually right for a security control; defaulting to the compliant value would make every unannotated Service pass, which is the same hole wearing a different hat. The other approach is to gate the rule so it only evaluates when the field exists, which is what preconditions are for — a separate mechanism worth knowing, but note it is a gate, not a fix: if you gate on the annotation existing, unannotated Services skip the rule and are admitted. For a fail-closed control the default is the safer construction. ### Two neighbouring traps in the same expression **Quoted identifiers.** A key containing a dot, a slash or a dash is not a bare JMESPath identifier. `metadata.annotations.net.example.com/internal-only` parses as a chain of nested lookups and finds nothing. The key must be double-quoted: `metadata.annotations."net.example.com/internal-only"`. Since annotation keys are usually domain-prefixed, nearly every annotation lookup needs this, and it fails in exactly the same silent-looking way as the absent field. **List projections.** An expression like `request.object.spec.ports[].port` is a projection: it returns a *list* of values, not a scalar, even when there is one element. Comparing that to a single value with `Equals` does not do what it looks like. Use `AnyIn` / `AllIn` / `AnyNotIn` / `AllNotIn`, which are defined over sets, and be deliberate about which one: "any port is in the forbidden set" and "all ports are in the forbidden set" are different controls, and picking the wrong one is another rule that silently under-blocks. ### How to find these before production The common thread is that all three failures are invisible if you only test with a resource that has the field. Build the fixtures from the *absence* side: a resource with the field set to the bad value, a resource with it set to the good value, and a resource with the field entirely missing. The third one is where the interesting bugs live. Run them against the policy whenever the rule changes, and treat a rule that errors as seriously as a rule that admits something — an erroring rule is a control that is not running, whatever the dashboard says about the policy being installed.

  • What default value would you choose for that annotation lookup, and why?
    An empty string. The rule denies when the annotation is not `"true"`, so defaulting to `''` makes an unset annotation fail the check and the Service is blocked — fail-closed, which is what a public-exposure control should do. Defaulting to `"true"` would resolve the error just as neatly and admit every unannotated Service, reintroducing the exact gap the rule exists to close.
  • Your condition compares spec.ports[].port to a single forbidden port and never fires. What is wrong?
    The expression is a projection, so it returns a list of ports rather than one value, and an equality operator against a scalar will not match it. Use a set operator — `AnyIn` if any port falling in the forbidden set should block, `AllIn` if the whole list must. Choosing between those two is a decision about the control, not a syntax detail.
  • How would you catch this class of bug before the policy reaches a cluster?
    Test from the absence side. Keep three fixtures beside the rule: the field set to a bad value, set to a good value, and missing entirely. The third catches unresolved expressions, unquoted domain-prefixed keys and projection mismatches, all of which look fine against a fully-populated resource. Treat a rule that errors as a failed test, not a warning.

saying these in an interview costs you the question

  • Assumes an absent field evaluates to false
  • Tests only with resources that have the field set
  • Writes an annotation key without double-quoting it
  • Compares a list projection with Equals
  • Defaults an unset field to the compliant value

context