Your Rego deny rule stopped firing after you moved its check into a helper — why?
answer
- the helper returned nothing, not false
- a body is a conjunction
- an empty deny set is a pass
- the fixture always had the field
- make it total, or deny explicitly
basics
~20 sThe helper is undefined for that input, and an undefined call makes the whole rule body undefined. A partial deny rule with an undefined body adds no message, so the image passes. Undefined is not false.
solid answer
~40 sA function whose body does not hold is undefined for that call, and undefined propagates: every expression in a rule body must hold, so one undefined call makes the body undefined and the `deny` set gets no element. An empty deny set is a pass. That is why factoring an inline check into `base_of(img)` can turn a blocking rule into a silent allow — the images that most deserve blocking are exactly the ones missing the label the helper reads. Fix it by making the helper total: read the optional field with `object.get` and a fallback, or give the function a `default` definition. Then add a separate `deny` for the missing-metadata case, so "we could not tell" is a violation rather than a gap, and test it with a fixture that omits the field.
code
rego · 14 linespackage image.base
approved_bases := {"registry.internal/base/distroless", "registry.internal/base/ubi9"}
# helper: pull the recorded base image out of the image config labels
base_of(img) := name if {
name := img.config.Labels["org.opencontainers.image.base.name"]
}
deny contains msg if {
base := base_of(input.image) # undefined when the label is absent
not base in approved_bases
msg := sprintf("base image %q is not approved", [base])
}go deeper
Know that a Rego expression that does not hold is undefined rather than false, and that a deny rule whose body is undefined adds no message at all.
Be ready to trace the propagation step by step: undefined call, undefined body, no set element, empty deny, allow. Then name the two ways to make the helper total.
Demonstrate the diagnosis — evaluate the helper alone against the failing input — and the durable fix: a total helper plus a separate violation for missing metadata, backed by a fixture that omits the field.
Own the standard that a control which cannot evaluate its input must never be indistinguishable from a clean pass, and make sure the evidence a green gate produces means what the people relying on it think it means.
## The refactor that quietly disarms a rule You had one long `deny` rule. You factored the field lookup into a helper so three rules could share it. Every test still passes, and a week later an image with no base-image label goes straight through. ``` base_of(img) := name if { name := img.config.Labels["org.opencontainers.image.base.name"] } deny contains msg if { base := base_of(input.image) not base in approved_bases msg := sprintf("base image %q is not approved", [base]) } ``` When the label is absent, the lookup inside `base_of` does not hold, so the function is **undefined** for that argument. It did not return `false`, `null`, or an empty string — it produced no result at all. ### How undefined travels through a call A rule body is a conjunction: every expression in it must hold for the body to hold. An undefined expression does not hold, so: 1. `base := base_of(input.image)` is undefined, 2. therefore the `deny` body is undefined, 3. therefore that definition contributes nothing to the `deny` set, 4. therefore `deny` is empty, 5. therefore the gate, which blocks only when `deny` is non-empty, allows the image. The failure mode is the dangerous direction: **the check disappears rather than firing**. And the inputs that trigger it are exactly the suspicious ones — images built outside the blessed pipeline are the ones with no base-image metadata to read. ### The mirror-image trap The same propagation bites the other way when the call is negated: ``` deny contains msg if { not is_approved_base(input.image) msg := "image is not built from an approved base" } ``` Here `not <undefined>` **succeeds**, so a missing label makes the rule fire. That is the fail-closed direction, which is usually what you want for a security control — but it is the same underlying mechanic, and if the reason for the undefined-ness is a typo in the label key rather than a genuinely missing label, you are now blocking every image in the estate for a reason your message does not explain. Whichever polarity you choose, you should choose it deliberately and know which one you wrote. ### Making a helper total Three tools, and they compose: - **`object.get` with a fallback.** `name := object.get(img, ["config", "Labels", "org.opencontainers.image.base.name"], "")` gives you a value for every input; the empty string then fails the membership test and the deny fires with a message you can read. - **A default definition for the function.** `default base_of(_) := ""` supplies a value whenever no other definition holds. Its arguments must all be wildcards, which is the limitation to remember. - **An explicit deny for missing metadata.** Separate "the base is not approved" from "the image records no base at all" and emit a distinct message for each. The second is the one the platform team needs, because it points at the build, not at the image contents. ### Why the tests were green Because fixtures are written from the happy path. Every sample image in the test data carried the label, so no test ever exercised the branch where the helper is undefined. The habit worth showing in an interview: for every optional field a policy reads, add a fixture where that field is **absent** and assert the decision you actually want. That single test is what distinguishes a policy someone wrote from a policy someone trusts. ### Diagnosing it in the moment When a rule mysteriously does not fire, evaluate the helper on its own with the offending input. A result of "undefined" — as opposed to `false` or an empty set — is the diagnosis, and it points at the exact expression inside the helper that failed to hold. This is far quicker than staring at the deny rule, because the deny rule is not where the problem is. ### The judgment behind the fix The deeper point is about defaults in a control. A gate that cannot evaluate its input has two honest options: block, or record that it could not decide. It has one dishonest option, which is to look identical to a clean pass. A helper that is undefined for missing data silently picks the dishonest one. Making helpers total, and giving "unknown" its own violation message, is how you keep the decision truthful — and it is what lets you tell an auditor what a green result on that gate actually means.
- If the call were negated with not, would the rule fire instead?Yes. `not <undefined>` succeeds, so a missing label would make the deny fire — the fail-closed direction. It is the same propagation rule, just observed through a negation. The danger there is over-blocking with an unhelpful message when the undefined-ness comes from a typo in a field path rather than genuinely absent data.
- How do you make a helper that reads an optional field always produce a value?Read it with `object.get` and a fallback, or add a default definition for the function, whose arguments must all be wildcards. Then decide what the fallback means: usually it should fail the subsequent check, so a missing field produces a violation with its own message rather than a gap.
- What test would have caught this before it shipped?A fixture with the label removed entirely, asserting that the decision is a denial with the missing-metadata message. Happy-path fixtures cannot catch it, because the bug only exists on the input shape none of them have.
A smoke detector wired so that a dead battery reads the same as clean air: the alarm is silent for exactly the reason you should be worried.
saying these in an interview costs you the question
- Says an undefined helper evaluates as false
- Assumes a failed lookup produces an error the gate will notice
- Expects a missing field to yield null or an empty string
- Trusts green tests built only from well-formed fixtures
- Fixes it by widening the approved list instead of handling absent data