Skip to content

fix(rulebinding): match namespaces by exact name, not String() substring - #402

Merged
matthyx merged 1 commit into
kubescape:mainfrom
manumathon:feat/rulebinding
Aug 3, 2026
Merged

fix(rulebinding): match namespaces by exact name, not String() substring#402
matthyx merged 1 commit into
kubescape:mainfrom
manumathon:feat/rulebinding

Conversation

@manumathon

@manumathon manumathon commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Overview

fixes #401
This PR fixes false-positive rulebinding matches when a namespace name is a substring of another (e.g. dev matching devel).

Namespace membership now checks exact names instead of strings.Contains(namespaces.String(), …). Invalid label selectors are skipped.

Signed Commits

  • Yes, I signed my commits.

How to Test

  1. Label devel with repro=selected; leave dev unlabeled.
  2. Bind rule R2002 with that namespace selector.
  3. Create pods in both namespaces — only devel should match.

Checklist before requesting a review

  • My code follows the style guidelines of this project
  • I have commented on my code, particularly in hard-to-understand areas
  • I have performed a self-review of my code
  • If it is a core feature, I have added thorough tests.
  • New and existing unit tests pass locally with my changes

Summary by CodeRabbit

  • Bug Fixes
    • Improved rule-binding label selector handling by reporting conversion errors and skipping invalid bindings.
    • Fixed namespace matching to require exact names, preventing unintended matches from partial or substring values.
    • Added safer handling for empty or unavailable namespace lists.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e6b11a08-b3c5-4080-bbf6-0072651ce5b2

📥 Commits

Reviewing files that changed from the base of the PR and between 1201668 and 5790044.

📒 Files selected for processing (3)
  • admission/rulebinding/cache/cache.go
  • admission/rulebinding/cache/helpers.go
  • admission/rulebinding/cache/helpers_test.go

📝 Walkthrough

Walkthrough

The rule-binding cache now logs selector parsing errors and skips invalid bindings. Namespace matching checks exact namespace names instead of substring matches. Tests cover exact, missing, substring, nil, and empty namespace lists.

Changes

Rule-binding selector fixes

Layer / File(s) Summary
Exact namespace membership
admission/rulebinding/cache/helpers.go, admission/rulebinding/cache/helpers_test.go
Added exact namespace lookup with nil and empty-list handling. Added tests for exact matches and rejected substring matches.
Selector parsing and cache integration
admission/rulebinding/cache/cache.go
Logs pod and namespace selector conversion errors, skips invalid bindings, and uses exact namespace membership checking.

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested reviewers: slashben

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary fix: exact namespace matching instead of substring matching.
Linked Issues check ✅ Passed The changes satisfy issue #401 by matching exact namespace names and preventing false-positive NamespaceSelector matches.
Out of Scope Changes check ✅ Passed All code and test changes directly support the linked issue and PR objective.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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 moved this to Needs Reviewer in KS PRs tracking Aug 3, 2026

@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.

Reviewed this against main in a local checkout of the branch: go build ./... and go vet ./admission/... are clean, and go test ./... is green (including admission/rulebinding/cache), so the unchecked "New and existing unit tests pass locally" box can be ticked.

The fix is correct. I reproduced #401 through ListRulesForObject (not just the helper) with a fake clientset: on main a pod in dev matches a binding whose namespaceSelector selects only devel; on this branch it does not. namespaceListHasName itself is right, including the nil and empty-list guards.

I found no merge-blocking defect in the change itself. Two things I'd still want before merge, and two follow-ups:

Must-fix

  1. Add a regression test at the ListRulesForObject level. TestNamespaceListHasName only tests the new helper in isolation — it would still pass if the call site on cache.go:122 were reverted to strings.Contains. The bug reported in #401 lives in the wiring, and the existing k8sinterface.NewKubernetesApiMock() already gives you a fake clientset whose Namespaces().List honours the label selector, so the test is cheap. Code inline below (verified: fails on main, passes here).
  2. The description undersells the error handling. Those two _err changes fix an unrecovered nil-pointer panic that crashes the operator, not just a missing log line. Details inline on cache.go:100. Please call it out in the PR body / release notes, and cover it with a test — it is the higher-severity half of this PR.

Follow-ups (not blocking)

  1. The identical bug is still live in node-agentpkg/rulebindingmanager/cache/cache.go has both strings.Contains(namespaces.String(), pod.GetNamespace()) and the same two discarded LabelSelectorAsSelector errors (checked v0.3.38, the version pinned in this repo's go.mod, and it is still there on newer tags). The operator only imports node-agent's types and interfaces, so this PR is complete for this repo — but a user hitting #401 through the runtime (non-admission) path is not fixed until node-agent is. Worth an issue there.
  2. The namespace check LISTs every matching namespace from the API server, once per rule binding, on every admission request — in the webhook latency path. A single Namespaces().Get(object.GetNamespace()) (ideally from an informer cache) plus nsSelector.Matches(labels.Set(ns.Labels)) is one cheap call, removes the fan-out, and removes the need for namespaceListHasName altogether. Detail inline on helpers.go.

Nits inline. Nice, tight fix — thanks for the clear comment explaining why String() must not be used.

podSelector, _ := metav1.LabelSelectorAsSelector(&rb.Spec.PodSelector)
podSelector, err := metav1.LabelSelectorAsSelector(&rb.Spec.PodSelector)
if err != nil {
logger.L().Error("failed to parse pod selector", helpers.String("ruleBiding", uniqueName(&rb)), helpers.Error(err))

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.

This is the most valuable change in the PR and the description doesn't mention it: on main these discarded errors are a crash vector, not a silent mismatch.

metav1.LabelSelectorAsSelector returns (nil, err) on a bad selector — it does not fall back to labels.Nothing(). So podSelector.Matches(...) on the old line dereferences a nil interface, and so does nsSelector.String() below. Verified on main with a RuntimeAlertRuleBinding whose podSelector uses an unknown operator:

panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation]
github.com/kubescape/operator/admission/rulebinding/cache.(*RBCache).ListRulesForObject(...)
	admission/rulebinding/cache/cache.go:100

There is no recover() anywhere under admission/ or in main.go (only mainhandler and restapihandler have one), and ListRulesForObject is called from the admission worker in admission/webhook/validator.go:282 — so one malformed CRD, which the API server will happily accept since the CRD schema doesn't validate operator values, takes down the operator process. With this branch the same input logs failed to parse pod selector ... "Bogus" is not a valid label selector operator and skips the binding (fail-closed, which is the right call for an admission matcher).

Two asks:

  • Mention this in the PR body / release notes — "fixes a panic that crashes the operator on an invalid rule binding selector" is a much bigger headline than "reports conversion errors".
  • Add a test for it. It's ~20 lines and needs no fixtures:
func TestListRulesForObjectInvalidSelectorDoesNotPanic(t *testing.T) {
	c := NewCacheMock()
	c.addRuleBinding(&typesv1.RuntimeAlertRuleBinding{
		ObjectMeta: metav1.ObjectMeta{Name: "badRB", Namespace: "kubescape"},
		Spec: typesv1.RuntimeAlertRuleBindingSpec{
			PodSelector: metav1.LabelSelector{
				MatchExpressions: []metav1.LabelSelectorRequirement{
					{Key: "app", Operator: "Bogus", Values: []string{"x"}},
				},
			},
			Rules: []typesv1.RuntimeAlertRuleBindingRule{{RuleID: "R2000"}},
		},
	})

	obj := &unstructured.Unstructured{Object: map[string]interface{}{
		"metadata": map[string]interface{}{
			"name": "p", "namespace": "ns",
			"labels": map[string]interface{}{"app": "x"},
		},
	}}

	assert.NotPanics(t, func() {
		assert.Empty(t, c.ListRulesForObject(context.Background(), obj))
	})
}

(Panics on main, passes on this branch.)

nsSelector, _ := metav1.LabelSelectorAsSelector(&rb.Spec.NamespaceSelector)
nsSelector, err := metav1.LabelSelectorAsSelector(&rb.Spec.NamespaceSelector)
if err != nil {
logger.L().Error("failed to parse namespace selector", helpers.String("ruleBiding", uniqueName(&rb)), helpers.Error(err))

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.

Two nits on both new log lines (this one and the pod-selector one above):

  • rbName is already computed from uniqueName(&rb) at the top of the loop (cache.go:81) — reuse it instead of recomputing.
  • "ruleBiding" is a typo. It's pre-existing on the failed to list namespaces line below, so it's consistent — but if it's being copied into two new lines, worth fixing all three to "ruleBinding" in the same commit. Anyone grepping logs or building a dashboard on this field will thank you.
logger.L().Error("failed to parse namespace selector", helpers.String("ruleBinding", rbName), helpers.Error(err))

continue
}
if !strings.Contains(namespaces.String(), object.GetNamespace()) {
if !namespaceListHasName(namespaces, object.GetNamespace()) {

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.

Must-fix: this call site — the one #401 is actually about — has no test.

TestNamespaceListHasName tests the helper in isolation, so it would stay green if this line were reverted to strings.Contains(namespaces.String(), object.GetNamespace()). Nothing in the suite exercises ListRulesForObject with a non-empty namespaceSelector at all, which is exactly why the bug survived this long.

The existing k8sinterface.NewKubernetesApiMock() backs KubernetesClient with a real kubernetesfake.NewSimpleClientset(), and the generated fake's Namespaces().List does apply the label selector — so this reproduces the reported scenario end to end with no new test infrastructure:

func TestListRulesForObjectNamespaceSelectorExactMatch(t *testing.T) {
	k8sAPI := k8sinterface.NewKubernetesApiMock()
	for _, ns := range []*corev1.Namespace{
		{ObjectMeta: metav1.ObjectMeta{Name: "devel", Labels: map[string]string{"repro": "selected"}}},
		{ObjectMeta: metav1.ObjectMeta{Name: "dev"}},
	} {
		_, err := k8sAPI.KubernetesClient.CoreV1().Namespaces().Create(context.Background(), ns, metav1.CreateOptions{})
		assert.NoError(t, err)
	}

	c := NewCacheMock()
	c.k8sClient = k8sAPI
	c.addRuleBinding(&typesv1.RuntimeAlertRuleBinding{
		ObjectMeta: metav1.ObjectMeta{Name: "testRB", Namespace: "kubescape"},
		Spec: typesv1.RuntimeAlertRuleBindingSpec{
			PodSelector:       metav1.LabelSelector{MatchLabels: map[string]string{"app": "testPod"}},
			NamespaceSelector: metav1.LabelSelector{MatchLabels: map[string]string{"repro": "selected"}},
			Rules:             []typesv1.RuntimeAlertRuleBindingRule{{RuleID: "R2000"}},
		},
	})

	pod := func(ns string) *unstructured.Unstructured {
		return &unstructured.Unstructured{Object: map[string]interface{}{
			"metadata": map[string]interface{}{
				"name": "testPod", "namespace": ns,
				"labels": map[string]interface{}{"app": "testPod"},
			},
		}}
	}

	assert.Len(t, c.ListRulesForObject(context.Background(), pod("devel")), 1, "selected namespace must match")
	assert.Len(t, c.ListRulesForObject(context.Background(), pod("dev")), 0, "substring namespace must NOT match")
}

I ran this both ways:

  • on main: Error: "[0xc000664810]" should have 0 item(s), but has 1 — substring namespace must NOT match
  • on this branch: PASS

Keep TestNamespaceListHasName too — it's good — but this is the one that pins the regression.

// namespaceListHasName reports whether name is an exact member of list.Items.
// Do not use strings.Contains(list.String(), name): NamespaceList.String() is a
// debug dump, so substrings (e.g. "dev" in "devel") falsely match.
func namespaceListHasName(list *corev1.NamespaceList, name string) bool {

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.

Follow-up, not blocking this PR — the helper is the right fix for the bug as written.

But it's worth noting why this helper has to exist: the caller LISTs every namespace in the cluster matching the selector, then linearly scans the result for one name. That LIST is issued once per rule binding, on every admission request, in the webhook's latency path. With N bindings carrying a namespaceSelector, a single pod create costs N cluster-wide namespace LISTs against the API server.

The question being asked is "does this one namespace match the selector", so it can be answered without the fan-out:

ns, err := c.k8sClient.GetKubernetesClient().CoreV1().Namespaces().Get(ctx, object.GetNamespace(), metav1.GetOptions{})
if err != nil {
	logger.L().Error("failed to get namespace", helpers.String("ruleBinding", rbName), helpers.Error(err))
	continue
}
if !nsSelector.Matches(labels.Set(ns.GetLabels())) {
	continue
}

One GET instead of a LIST (and ideally served from an informer/lister cache rather than the API server at all), no nsSelector.String() round-trip through ListOptions, and namespaceListHasName disappears — along with the whole class of bug it's guarding against. Same treatment would apply to node-agent's copy of this loop.

@matthyx
matthyx merged commit ebf96d8 into kubescape:main Aug 3, 2026
11 checks passed
@matthyx matthyx moved this from Needs Reviewer to To Archive in KS PRs tracking Aug 4, 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.

admission/rulebinding: NamespaceSelector match uses strings.Contains on NamespaceList.String()

2 participants