skip to content

Why does a Rego deny rule stop denying, without erroring, when the field it reads is missing?

level: middleimportance: must knowfreq 60%

answer

  1. undefined, not false, not an error
  2. the first reference never resolved
  3. iteration over nothing binds nothing
  4. a partial rule adds no element
  5. empty deny set looks exactly like clean

basics

~20 s

A missing key makes the reference undefined rather than false, and undefined propagates through the whole body. The rule then contributes no message, the deny set stays empty, and the gate reports a clean pass with nothing to alert on.

solid answer

~40 s

The rule below assumes a Deployment: it walks `input.spec.template.spec.containers`. Hand it a bare Pod, whose containers sit at `spec.containers`, or a CronJob, whose pod template sits under `spec.jobTemplate.spec.template.spec`, and that first reference is undefined. Undefined is neither an error nor false — no bindings satisfied the expression, so the iteration never starts, the body never completes, and the partial rule adds no message to `deny`. The caller sees an empty set and reads it as no violations. Nothing distinguishes “every container was clean” from “I never found a container”. The repairs: normalise the container path in a helper, add a rule that denies any document whose containers you cannot resolve, and keep a deliberately non-compliant fixture in CI asserting that the rule still denies.

code

rego · 13 lines
rego
package k8s.secrets

# `input` is the workload manifest as submitted
deny contains msg if {
	c := input.spec.template.spec.containers[_]
	e := c.env[_]
	e.value                      # a literal credential in the manifest
	not e.valueFrom
	msg := sprintf("%s: env %s is set inline; use valueFrom.secretKeyRef", [c.name, e.name])
}

# a Pod has spec.containers; a CronJob has
# spec.jobTemplate.spec.template.spec.containers -> both are undefined here

go deeper

for a junior

Know that reading a path that is not there gives you undefined, and that undefined produces no denial and no error. Be able to point at the first reference in a body as the thing that has to resolve.

for a middle

Explain the propagation step by step: reference undefined, no bindings, body undefined, partial rule contributes nothing, empty set. Be ready to say why a default on a partial rule is both illegal and beside the point.

for a senior

Show the repair as a pattern, not a patch: normalise the workload shapes you support in one helper, add a rule that denies documents you cannot read, and gate every policy change on a must-deny fixture.

for a principal

Argue the standard: a rule counts as an enforced control only if something in the system continuously proves it can still deny. Own what that requirement costs the teams writing rules and why it is non-negotiable.

## What actually happens The rule is trying to enforce one thing: a credential must not be pasted into a pod spec as a literal `env[].value`; it has to come from `valueFrom.secretKeyRef`. The body reads containers out of a Deployment-shaped document, iterates their environment entries, and emits a message for each entry that has an inline `value` and no `valueFrom`. Every expression in that body depends on the first reference resolving. When the document is not Deployment-shaped, `input.spec.template.spec.containers` is **undefined** — there are no bindings for the iteration variable, because there is nothing at that path to iterate. Undefined propagates: the remaining expressions are never evaluated, the body as a whole is undefined, and a partial rule with an undefined body contributes **no element** to the set it builds. So the deny set is empty. Not false, not an error — empty. And an empty deny set is exactly what a compliant object produces. ## Why Rego behaves this way This is not a wart; it is the evaluation model. A Rego rule does not run a procedure that can throw. It defines the content of a virtual document by finding the variable bindings that satisfy its body. "No bindings satisfy this body" is the normal, expected outcome for the overwhelming majority of rule/input pairs — it is how a rule declines to match. If a reference into absent data were an error, every rule that legitimately does not apply to the object in front of it would blow up. The cost of that design is that a rule which *should* match but cannot read the document is indistinguishable from a rule that correctly did not match. Both are silent. ## The shapes that trip this rule A Kubernetes pod template appears at a different path depending on the workload kind: | Kind | Path to containers | | --- | --- | | Pod | `spec.containers` | | Deployment, StatefulSet, DaemonSet, ReplicaSet | `spec.template.spec.containers` | | Job | `spec.template.spec.containers` | | CronJob | `spec.jobTemplate.spec.template.spec.containers` | A rule hard-coded to one of these silently ignores the others. The same failure arrives without anyone renaming anything: a team adopts CronJobs, or a chart starts emitting bare Pods, and the guardrail that was "on" simply stops applying to the new traffic. Other paths to the same silence: a field genuinely renamed under you, `initContainers` never being walked at all, or a built-in erroring mid-body — OPA's default is to treat a failing built-in as undefined, so `to_number` or `split` on unexpected data drops the rest of the body exactly as a missing key does. ## Why `default` is not the fix here The reflex is to write `default deny := false`. Two things are wrong with it. First, `default` may only be attached to a **complete** rule; `deny contains msg` is a partial set rule, and OPA rejects the policy at load time if you declare a default for it. Second, and more important, `default` only supplies a value when the body is undefined — it never makes an undefined body *match*. A defaulted rule still fails to notice the violating container. A default guarantees the decision has a value; it guarantees nothing about the decision being correct. ## Repairs that actually work **Normalise the shape, deliberately.** Collect containers from every path you intend to support in one helper, so each supported kind is written down once and reviewed. **Fail loudly on shapes you cannot read.** Add a companion rule that denies (or at minimum flags) a document whose containers could not be resolved from any known path. That converts a silent pass into a visible, fixable failure — and it is the only mechanism that tells you about a shape you have not met yet. **Test the counterexample, not just the happy path.** Every rule needs a fixture that must be denied — a Deployment with `env: [{name: DB_PASSWORD, value: "..."}]` — asserted in CI on every policy change. A test suite that only proves compliant objects pass is satisfied by a rule that denies nothing, which is exactly the broken state you are trying to catch. **Make errors visible where you can.** Running with strict built-in errors turns the silently-degrading class of failure into an explicit one, at the price of a hard failure on data you did not anticipate — a trade worth making in CI even if you are cautious about it at admission time. **Read zero with suspicion.** When a report says a rule found nothing, the first question is not "are we clean?" but "can this rule still deny anything at all?"

  • Would adding `default deny := false` fix this rule?
    No, twice over. `default` can only be declared on a complete rule; `deny contains msg` is a partial set rule and OPA rejects the policy at load time if you attach one. And even on a complete rule a default only supplies a value when the body is undefined — it never makes the body match the violating container. The fix is to resolve the container path correctly and to fail loudly when you cannot.
  • How would you catch this before it reaches production?
    Keep a deliberately non-compliant fixture per rule — a manifest with an inline credential — and assert in CI that evaluating the rule against it yields a denial with the expected message. Add fixtures for each workload shape you claim to support. A rule that cannot deny its own counterexample is broken no matter how green the pipeline looks.
  • A container in the manifest has no `env` field at all. Is that the same bug?
    Same mechanism, different consequence. `c.env[_]` is undefined for that container, so it contributes nothing — which is correct, since a container with no environment variables cannot violate this rule. The danger is only when the undefined path is one you assumed would always be there, because then absence is masquerading as compliance.

saying these in an interview costs you the question

  • Says OPA would have raised a missing-field error
  • Claims an empty deny set proves the manifests were clean
  • Adds a default to a partial set rule
  • Reads undefined as false and still expects a denial
  • Ships a rule with only compliant test fixtures

context