Skip to content

fix: an exception with no Resources matches everywhere, not nowhere - #189

Merged
matthyx merged 1 commit into
kubescape:mainfrom
doraem-on:fix-3368
Aug 20, 2026
Merged

matthyx merged 1 commit into
kubescape:mainfrom
doraem-on:fix-3368

Conversation

@doraem-on

Copy link
Copy Markdown
Member

Summary

Fixes kubescape/kubescape#3368. Implements option 1 from the discussion there, per @matthyx's call: opa-utils treats an empty Resources list as "matches everywhere."

getResourceExceptions found a match by iterating ruleException.Resources:

for _, resourceToPin := range ruleException.Resources {
    resource := resourceToPin
    if p.hasException(clusterName, &resource, workload, failingContainerNames, selector) {
        postureExceptionPolicy = append(postureExceptionPolicy, ruleException)
    }
}

An exception with an empty Resources list has zero iterations here, so it matched nothing — even though kubescape's own hasExplicitControlException (core/pkg/opaprocessor/processorhandlerutils.go) already documents and relies on "no resources = no scope constraint" for manual controls:

// no resources = no scope constraint, matches any cluster
if len(policy.Resources) == 0 {
    return true
}

Same exception shape, opposite outcome, depending only on whether the target control happened to be manual-review (kubescape's own code) or resource-backed (this function).

Fix

  • hasException is split into its leading guard (rejects a populated PortalDesignator whose fields are all empty — a different footgun: a misconfigured, accidentally-wildcard designator) and a new hasExceptionForAttributes, which is the rest of the original logic (cluster check, RegoResponseVector handling, ObjectSelector, metadataHasException).
  • getResourceExceptions gets an explicit len(ruleException.Resources) == 0 branch that calls hasExceptionForAttributes directly with a digested all-empty PortalDesignator, instead of hand-rolling a simplified match that could drift from the real RegoResponseVector/ObjectSelector logic.
  • An ObjectSelector, if present on a scope-less exception, still constrains which workloads it applies to — only the designator axis (cluster/namespace/name/kind/labels/path) is left unconstrained. This is deliberate and matches @matthyx's point in the issue thread: the guard being bypassed protects against a different footgun than the one this fix addresses.

Semver / consumer impact (per @matthyx's request in the issue thread)

This is a real, semver-relevant behavior change for GetResourceExceptions and SetRuleResponsExceptions: any caller that (deliberately or not) relied on an empty-Resources exception matching nothing will now see it match everything the exception's ObjectSelector/PosturePolicies scope it to.

Checked for other consumers before opening this:

  • GitHub code search (org kubescape and unrestricted) for direct calls to GetResourceExceptions / SetRuleResponsExceptions: only hits inside opa-utils itself (exceptionprocessor.go, resourcesresults/exceptions.go, and their tests).
  • GitHub code search for exceptions.NewProcessor and resourcesresults.SetExceptions (the higher-level entry points that reach this code): only kubescape/kubescape and opa-utils itself.
  • kubevuln, operator, backend, regolibrary, and synchronizer all depend on opa-utils (per go.mod) but none of them call into this exception-matching path — they use it for its report/data types only.

So kubescape/kubescape is the only known real consumer of this behavior change, and that's exactly the repo the issue was filed against.

How this was verified

Added 4 tests to exceptions/exceptionprocessor_test.go:

  1. TestGetResourceExceptions_EmptyResourcesMatchesEverywhere — a scope-less exception matches an arbitrary workload on an arbitrary cluster.
  2. TestGetResourceExceptions_EmptyResourcesWithObjectSelectorStillConstrains — a scope-less exception with an ObjectSelector matches a labeled workload but not an unlabeled one (confirms the fix is additive, not a blanket bypass).
  3. TestGetResourceExceptions_EmptyResourcesDifferentControlDoesNotMatch — the scope-less match is still gated by PosturePolicies/control-name through the normal ListRuleExceptions pipeline.
  4. TestSetRuleResponsExceptions_EmptyResourcesMatchesEverywhere — the fix is reachable through SetRuleResponsExceptions (the entry point SetRuleExceptions/SetControlExceptions/SetFrameworkExceptions actually use), not just GetResourceExceptions directly.

Confirmed all 4 are real regression tests by running them against the pre-fix code (git stash on just exceptionprocessor.go):

$ git stash push -- exceptions/exceptionprocessor.go
$ go test ./exceptions/... -run "TestGetResourceExceptions_EmptyResources|TestSetRuleResponsExceptions_EmptyResources" -v
--- FAIL: TestGetResourceExceptions_EmptyResourcesMatchesEverywhere (0.00s)
    Messages: a scope-less exception (no Resources) must match an arbitrary workload on an arbitrary cluster
--- FAIL: TestGetResourceExceptions_EmptyResourcesWithObjectSelectorStillConstrains (0.00s)
    Messages: the workload carries the selected label, so the scope-less exception should still apply
--- FAIL: TestGetResourceExceptions_EmptyResourcesDifferentControlDoesNotMatch (0.00s)
--- FAIL: TestSetRuleResponsExceptions_EmptyResourcesMatchesEverywhere (0.00s)
    Messages: a scope-less exception must still be attached via SetRuleResponsExceptions
FAIL
$ git stash pop

Real test output (this run, on this branch, with the fix applied)

$ go build ./...
$ go vet ./...
(both exit 0, no output)

$ go test ./exceptions/... -run "TestGetResourceExceptions_EmptyResources|TestSetRuleResponsExceptions_EmptyResources" -v
=== RUN   TestGetResourceExceptions_EmptyResourcesMatchesEverywhere
--- PASS: TestGetResourceExceptions_EmptyResourcesMatchesEverywhere (0.00s)
=== RUN   TestGetResourceExceptions_EmptyResourcesWithObjectSelectorStillConstrains
--- PASS: TestGetResourceExceptions_EmptyResourcesWithObjectSelectorStillConstrains (0.00s)
=== RUN   TestGetResourceExceptions_EmptyResourcesDifferentControlDoesNotMatch
--- PASS: TestGetResourceExceptions_EmptyResourcesDifferentControlDoesNotMatch (0.00s)
=== RUN   TestSetRuleResponsExceptions_EmptyResourcesMatchesEverywhere
--- PASS: TestSetRuleResponsExceptions_EmptyResourcesMatchesEverywhere (0.00s)
PASS
ok  	github.com/kubescape/opa-utils/exceptions	1.023s

$ go test ./...
(all packages pass, no FAIL anywhere)

$ golangci-lint run ./exceptions/...
exceptions/designators_cache_test.go:4: File is not `goimports`-ed (goimports)

That one finding is in designators_cache_test.go, a file this PR does not touch — confirmed pre-existing on main and unrelated (git diff --name-only shows only exceptionprocessor.go and exceptionprocessor_test.go).

$ gofmt -l exceptions/exceptionprocessor.go exceptions/exceptionprocessor_test.go
(no output — both files formatted)

Test plan

  • go build ./...
  • go vet ./...
  • golangci-lint run — 0 issues in changed files (one pre-existing, unrelated finding elsewhere)
  • gofmt -l — no unformatted files
  • 4 new regression tests added, all confirmed to fail against the pre-fix code and pass with the fix
  • Full test suite passes, no regressions
  • Checked for other consumers of the changed behavior, as requested in the issue thread

cc @matthyx

getResourceExceptions iterated ruleException.Resources to find a
match, so an exception with an empty Resources list was matched
against zero designators and therefore matched nothing - even though
kubescape's own hasExplicitControlException already documents and
relies on "no resources = no scope constraint" for manual controls.
The same shape of exception behaved oppositely depending only on
whether the target control happened to be manual-review or
resource-backed.

Added an explicit "Resources is empty = matches everywhere" branch in
getResourceExceptions. It reuses hasException's existing matching
logic (cluster check, RegoResponseVector handling, ObjectSelector)
via a new hasExceptionForAttributes helper - hasException split into
the leading guard (which rejects a populated-but-all-empty-fielded
PortalDesignator, a different footgun from an empty Resources list)
and the rest, so the new branch can go straight to the rest with a
digested all-empty PortalDesignator instead of hand-rolling a
simplified version of RegoResponseVector/ObjectSelector handling that
could drift from the real logic.

An ObjectSelector, if present on a scope-less exception, still
constrains which workloads it applies to - only the designator axis
is left unconstrained. Verified with tests covering: the basic
matches-everywhere case, ObjectSelector still gating a scope-less
exception, PosturePolicies/control-name gating being unaffected, and
the fix reachable through SetRuleResponsExceptions (not just
GetResourceExceptions directly).

This is a real, semver-relevant behavior change: any consumer of
GetResourceExceptions or SetRuleResponsExceptions that (deliberately
or not) relied on an empty-Resources exception matching nothing will
now see it match everything the exception's ObjectSelector and
PosturePolicies scope it to. Checked for other consumers via GitHub
code search across the kubescape org and beyond (GetResourceExceptions,
SetRuleResponsExceptions, exceptions.NewProcessor,
resourcesresults.SetExceptions): kubescape/kubescape is the only
repo, aside from opa-utils itself, that calls into this exception-
matching path at all. kubevuln, operator, backend, regolibrary and
synchronizer all depend on opa-utils for its data types but do not
call these functions.

Signed-off-by: Lalit Kishore <lr_be24@thapar.edu>
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bc03c8e8-15ec-4f27-805b-7505169674d3


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@matthyx matthyx left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified locally (checked out pr-189, go build ./..., go vet ./..., go test ./..., gofmt -l) — all clean, no regressions across the module.

Also confirmed the 4 new tests are real regression tests, not tautologies: reverted just exceptionprocessor.go to main and re-ran them — all 4 fail against pre-fix code (getResourceExceptions returns 0 matches for a scope-less exception) and pass with the fix applied.

The approach (split hasException into a guard + hasExceptionForAttributes, add an explicit len(Resources) == 0 branch that calls the split-out helper with a digested empty designator) is exactly what was discussed and agreed on in kubescape/kubescape#3368 — option 1, empty Resources = matches everywhere, with ObjectSelector still constraining scope-less exceptions. The guard-bypass rationale is correctly reasoned: the guard in hasException protects against a populated-but-all-empty-fielded PortalDesignator (a different footgun), not against Resources itself being an empty list.

Consumer check looks solid — only kubescape/kubescape calls into this matching path per the described search; other opa-utils consumers only use the data types.

LGTM, approving.

@matthyx
matthyx merged commit 13c546f into kubescape:main Aug 20, 2026
5 checks passed
@matthyx matthyx moved this to To Archive in KS PRs tracking Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

An exception with no resources scope applies everywhere for manual controls, but nowhere for resource-backed findings

2 participants