From 46398117e44828841989c9e1dc2cfcfd94eab0d3 Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Wed, 2 Sep 2026 20:28:51 +0000 Subject: [PATCH 01/33] fix: [NPM] compile multi-value namespaceSelector NotIn as a single conjunction A namespaceSelector matchExpression using NotIn with more than one value is a single set-membership requirement: key NotIn [a, b] means key != a AND key != b. The v2 selector compiler flattened multi-value NotIn the same way it flattens multi-value In, emitting one selector per value. Each flattened selector becomes an independent allow decision, and allow decisions are additive (OR), so a namespace carrying one of the values could still be matched by the decision negating a different value. That does not match LabelSelectorAsSelector semantics for multi-value NotIn. Handle the two operators separately in flattenNameSpaceSelector: In still fans out into one selector per value, while NotIn keeps every value as its own single-value NotIn requirement within the same selector. When a selector mixes the two, each NotIn exclusion is carried conjunctively into every In branch. Also fail closed on unsupported operators and empty In/NotIn value lists rather than dropping the requirement, which would widen the selector. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controlplane/translation/parseSelector.go | 99 ++++++++-- .../translation/parseSelector_test.go | 122 ++++++++++++ .../translation/translatePolicy.go | 8 + .../translation/translatePolicy_test.go | 187 ++++++++++++++++++ 4 files changed, 397 insertions(+), 19 deletions(-) diff --git a/npm/pkg/controlplane/translation/parseSelector.go b/npm/pkg/controlplane/translation/parseSelector.go index 447d1283058..f2e843c1e23 100644 --- a/npm/pkg/controlplane/translation/parseSelector.go +++ b/npm/pkg/controlplane/translation/parseSelector.go @@ -2,7 +2,6 @@ package translation import ( "fmt" - "regexp" "github.com/Azure/azure-container-networking/log" @@ -20,34 +19,47 @@ var validLabelRegex = regexp.MustCompile("(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0 // into multiple label selectors helping with the OR condition. func flattenNameSpaceSelector(nsSelector *metav1.LabelSelector) ([]metav1.LabelSelector, error) { /* - This function helps to create multiple labelSelectors when given a single multivalue nsSelector - Take below example: this nsSelector has 2 values in a matchSelector. + This function helps to create multiple labelSelectors when given a single multivalue nsSelector. + + The two multi-value operators are handled differently because they carry different semantics: + + In: a multi-value In is a disjunction (OR) over its values, so it is fanned out into one + labelSelector per value. Take below example with 2 values in a matchExpression: - namespaceSelector: matchExpressions: - key: ns - operator: NotIn + operator: In values: - netpol-x - netpol-y - goal is to convert this single nsSelector into multiple nsSelectors to preserve OR condition - between multiple values of the matchExpr i.e. this function will return + becomes - namespaceSelector: matchExpressions: - key: ns - operator: NotIn + operator: In values: - netpol-x - namespaceSelector: matchExpressions: - key: ns - operator: NotIn + operator: In values: - netpol-y - then, translate policy will replicate each of these nsSelectors to add two different rules in iptables, - resulting in OR condition between the values. + then, translate policy will replicate each of these nsSelectors to add two different rules, + resulting in the OR condition between the values. + + NotIn: a multi-value NotIn is a single set-membership conjunction, i.e. + ns NotIn [x, y] means (ns != x AND ns != y). It must NOT be fanned out into separate + selectors, because each generated selector becomes an independent allow rule and allow + rules are additive (OR): a namespace carrying one excluded value would still match the + rule negating the other value and be admitted. Instead, every value is kept as its own + single-value NotIn requirement within the same selector, so all negated conditions land + in a single decision (AND) and the default drop stays effective for every excluded value. + When a selector mixes In and NotIn, each NotIn exclusion is carried conjunctively into + every In branch. Check TestFlattenNameSpaceSelector 2nd subcase for complex scenario */ @@ -70,14 +82,20 @@ func flattenNameSpaceSelector(nsSelector *metav1.LabelSelector) ([]metav1.LabelS } multiValuePresent := false + // notInExpanded records whether a multi-value NotIn was rewritten into several + // single-value NotIn requirements on baseSelector. When it is, baseSelector no + // longer equals the input, so the original selector must not be returned as-is. + notInExpanded := false multiValueMatchExprs := []metav1.LabelSelectorRequirement{} for _, req := range nsSelector.MatchExpressions { - // Only In and NotIn operators of matchExprs have multiple values - // NPM will ignore single value matchExprs of these operators. - // for multiple values, it will create a slice of them to be used for Zipping with baseSelector - // to create multiple nsSelectors to preserve OR condition across all labels and expressions + // In/NotIn requirements carry the values; single-value requirements are added to + // baseSelector as-is, while multi-value requirements are handled per operator below. + // Exists/DoesNotExist carry no values and are added to baseSelector directly. switch { - case (req.Operator == metav1.LabelSelectorOpIn) || (req.Operator == metav1.LabelSelectorOpNotIn): + case req.Operator == metav1.LabelSelectorOpIn: + if len(req.Values) == 0 { + return nil, ErrEmptyMatchExpressionValues + } for _, v := range req.Values { if !isValidLabelValue(v) { return nil, ErrInvalidMatchExpressionValues @@ -88,21 +106,64 @@ func flattenNameSpaceSelector(nsSelector *metav1.LabelSelector) ([]metav1.LabelS // for length 1, add the matchExpr to baseSelector baseSelector.MatchExpressions = append(baseSelector.MatchExpressions, req) } else { + // multi-value In is a disjunction: zip it with baseSelector to + // create one nsSelector per value and preserve the OR condition. multiValuePresent = true multiValueMatchExprs = append(multiValueMatchExprs, req) } + case req.Operator == metav1.LabelSelectorOpNotIn: + if len(req.Values) == 0 { + return nil, ErrEmptyMatchExpressionValues + } + for _, v := range req.Values { + if !isValidLabelValue(v) { + return nil, ErrInvalidMatchExpressionValues + } + } + + if len(req.Values) == 1 { + // for length 1, add the matchExpr to baseSelector + baseSelector.MatchExpressions = append(baseSelector.MatchExpressions, req) + } else { + // A multi-value NotIn is a single set-membership conjunction + // (key NotIn [a, b] == key != a AND key != b), NOT a disjunction. + // Fanning it out into separate selectors would emit independent + // additive allow rules and let each excluded value be admitted by + // the rule negating another value. Keep every value as its own + // single-value NotIn within the same selector so all negations + // stay in one decision (AND). + notInExpanded = true + for _, v := range req.Values { + baseSelector.MatchExpressions = append( + baseSelector.MatchExpressions, + metav1.LabelSelectorRequirement{ + Key: req.Key, + Operator: metav1.LabelSelectorOpNotIn, + Values: []string{v}, + }, + ) + } + } case (req.Operator == metav1.LabelSelectorOpExists) || (req.Operator == metav1.LabelSelectorOpDoesNotExist): // since Exists and NotExists do not contain any values, NPM can safely add them to the baseSelector baseSelector.MatchExpressions = append(baseSelector.MatchExpressions, req) default: - log.Errorf("Invalid operator [%s] for selector [%v] requirement", req.Operator, *nsSelector) + // Fail closed: an unknown operator must not silently drop the requirement + // and widen the selector. Kubernetes only admits In/NotIn/Exists/DoesNotExist. + log.Errorf("unsupported operator [%s] for selector [%v] requirement", req.Operator, *nsSelector) + return nil, ErrUnsupportedMatchExpressionOperator } } - // If there are no multiValue NS selector match expressions - // return the original NsSelector + // If there are no multiValue In match expressions to fan out, the baseSelector + // (which already carries any conjunctive NotIn expansions) is the only selector. if !multiValuePresent { - return []metav1.LabelSelector{*nsSelector}, nil + if !notInExpanded { + // Nothing was rewritten; return the original selector unchanged so callers + // that compare against the input see an identical selector. + return []metav1.LabelSelector{*nsSelector}, nil + } + return []metav1.LabelSelector{*baseSelector.DeepCopy()}, nil } // Now use the baseSelector and loop over multiValueMatchExprs to create all diff --git a/npm/pkg/controlplane/translation/parseSelector_test.go b/npm/pkg/controlplane/translation/parseSelector_test.go index e93f99500ae..45357af8644 100644 --- a/npm/pkg/controlplane/translation/parseSelector_test.go +++ b/npm/pkg/controlplane/translation/parseSelector_test.go @@ -599,6 +599,128 @@ func TestFlattenNamespaceSelectorError(t *testing.T) { } } +// TestFlattenNameSpaceSelectorMultiValueNotIn verifies that a multi-value NotIn +// requirement is preserved as a single conjunction rather than fanned out into +// separate selectors. Separate selectors would become independent additive allow +// rules, so a namespace carrying one excluded value could still match the rule +// negating a different value. +func TestFlattenNameSpaceSelectorMultiValueNotIn(t *testing.T) { + selector := &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "tenant", + Operator: metav1.LabelSelectorOpNotIn, + Values: []string{"x", "y"}, + }, + }, + } + + testSelectors, err := flattenNameSpaceSelector(selector) + require.NoError(t, err) + + expected := []metav1.LabelSelector{ + { + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "tenant", + Operator: metav1.LabelSelectorOpNotIn, + Values: []string{"x"}, + }, + { + Key: "tenant", + Operator: metav1.LabelSelectorOpNotIn, + Values: []string{"y"}, + }, + }, + }, + } + + require.Equal(t, expected, testSelectors) +} + +// TestFlattenNameSpaceSelectorMixedInAndNotIn verifies that multi-value In values +// fan out into disjunctive branches while every multi-value NotIn exclusion is +// carried conjunctively into each branch. +func TestFlattenNameSpaceSelectorMixedInAndNotIn(t *testing.T) { + selector := &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "tenant", + Operator: metav1.LabelSelectorOpNotIn, + Values: []string{"x", "y"}, + }, + { + Key: "role", + Operator: metav1.LabelSelectorOpIn, + Values: []string{"a", "b"}, + }, + }, + } + + testSelectors, err := flattenNameSpaceSelector(selector) + require.NoError(t, err) + + // Two In branches, each carrying both NotIn exclusions conjunctively. + require.Len(t, testSelectors, 2) + for _, s := range testSelectors { + var notInValues []string + var inValues []string + for _, req := range s.MatchExpressions { + require.Len(t, req.Values, 1, "every requirement must be single-value after flatten") + switch req.Operator { + case metav1.LabelSelectorOpNotIn: + require.Equal(t, "tenant", req.Key) + notInValues = append(notInValues, req.Values[0]) + case metav1.LabelSelectorOpIn: + require.Equal(t, "role", req.Key) + inValues = append(inValues, req.Values[0]) + default: + t.Fatalf("unexpected operator %s", req.Operator) + } + } + require.ElementsMatch(t, []string{"x", "y"}, notInValues, "both exclusions must be present in every branch") + require.Len(t, inValues, 1) + } +} + +// TestFlattenNameSpaceSelectorUnsupportedOperator verifies that a matchExpression with +// an operator other than In/NotIn/Exists/DoesNotExist is rejected (fail closed) rather +// than silently dropped, which could otherwise widen the selector. +func TestFlattenNameSpaceSelectorUnsupportedOperator(t *testing.T) { + selector := &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "tenant", + Operator: metav1.LabelSelectorOperator("Frobnicate"), + Values: []string{"x"}, + }, + }, + } + s, err := flattenNameSpaceSelector(selector) + require.ErrorIs(t, err, ErrUnsupportedMatchExpressionOperator) + require.Nil(t, s) +} + +// TestFlattenNameSpaceSelectorEmptyValues verifies that In/NotIn requirements with +// no values are rejected (fail closed) rather than silently dropped, which could +// otherwise widen a selector or produce no rules at all. +func TestFlattenNameSpaceSelectorEmptyValues(t *testing.T) { + for _, op := range []metav1.LabelSelectorOperator{metav1.LabelSelectorOpIn, metav1.LabelSelectorOpNotIn} { + selector := &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "tenant", + Operator: op, + Values: []string{}, + }, + }, + } + s, err := flattenNameSpaceSelector(selector) + require.ErrorIs(t, err, ErrEmptyMatchExpressionValues, "operator %s", op) + require.Nil(t, s) + } +} + func TestIsValidLabel(t *testing.T) { good := []string{ "", diff --git a/npm/pkg/controlplane/translation/translatePolicy.go b/npm/pkg/controlplane/translation/translatePolicy.go index 8a647940104..777263d2381 100644 --- a/npm/pkg/controlplane/translation/translatePolicy.go +++ b/npm/pkg/controlplane/translation/translatePolicy.go @@ -35,6 +35,14 @@ var ( ErrInvalidMatchExpressionValues = errors.New( "matchExpression label values must be an empty string or consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character", ) + // ErrEmptyMatchExpressionValues is returned when an In or NotIn matchExpression carries no values. + // Kubernetes rejects such requirements; NPM fails closed rather than dropping the requirement, + // which could otherwise widen a selector (e.g. a dropped NotIn) or yield no rules at all. + ErrEmptyMatchExpressionValues = errors.New("In and NotIn matchExpression requirements must have at least one value") + // ErrUnsupportedMatchExpressionOperator is returned when a matchExpression uses an operator that is + // none of In, NotIn, Exists or DoesNotExist. NPM fails closed rather than dropping the requirement, + // which could otherwise silently widen the selector. + ErrUnsupportedMatchExpressionOperator = errors.New("unsupported matchExpression operator") // ErrUnsupportedIPAddress is returned when an unsupported IP address, such as IPV6, is used ErrUnsupportedIPAddress = errors.New("unsupported IP address") // ErrUnsupportedNonCIDR is returned when non-CIDR blocks are passed in with NPM Lite enabled. NPM Lite allows deny-all and allow-all policies diff --git a/npm/pkg/controlplane/translation/translatePolicy_test.go b/npm/pkg/controlplane/translation/translatePolicy_test.go index 29fa77407ea..d0a911e5e10 100644 --- a/npm/pkg/controlplane/translation/translatePolicy_test.go +++ b/npm/pkg/controlplane/translation/translatePolicy_test.go @@ -1268,6 +1268,193 @@ func TestNameSpaceSelector(t *testing.T) { } } +// TestNameSpaceSelectorMultiValueNotIn verifies that a namespaceSelector with a +// single multi-value NotIn requirement is translated (after flatten, as translateRule +// does) into one decision carrying a negated match-set for every excluded value. +// Emitting these as separate allow rules would be additive (OR) and admit a namespace +// that carries any one of the excluded values. +func TestNameSpaceSelectorMultiValueNotIn(t *testing.T) { + matchType := policies.SrcMatch + selector := &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "tenant", + Operator: metav1.LabelSelectorOpNotIn, + Values: []string{"x", "y"}, + }, + }, + } + + flattened, err := flattenNameSpaceSelector(selector) + require.NoError(t, err) + // The NotIn conjunction must stay in a single selector, not fan out. + require.Len(t, flattened, 1) + + _, nsSelectorList := nameSpaceSelector(matchType, &flattened[0]) + + expected := []policies.SetInfo{ + policies.NewSetInfo("tenant:x", ipsets.KeyValueLabelOfNamespace, nonIncluded, matchType), + policies.NewSetInfo("tenant:y", ipsets.KeyValueLabelOfNamespace, nonIncluded, matchType), + } + require.ElementsMatch(t, expected, nsSelectorList) +} + +// TestNameSpaceSelectorMatchLabelsAndMultiValueNotIn covers a namespaceSelector that +// combines matchLabels with a multi-value NotIn matchExpression. The matchLabels set +// must be ANDed into the same decision as the two negated values (a positive match plus +// two negated matches in one ACL), matching Kubernetes' conjunction of all requirements. +func TestNameSpaceSelectorMatchLabelsAndMultiValueNotIn(t *testing.T) { + matchType := policies.SrcMatch + selector := &metav1.LabelSelector{ + MatchLabels: map[string]string{"team": "blue"}, + MatchExpressions: []metav1.LabelSelectorRequirement{ + {Key: "tenant", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"x", "y"}}, + }, + } + + flattened, err := flattenNameSpaceSelector(selector) + require.NoError(t, err) + // matchLabels + a single conjunctive NotIn must stay in ONE selector, not fan out. + require.Len(t, flattened, 1) + + _, nsSelectorList := nameSpaceSelector(matchType, &flattened[0]) + + expected := []policies.SetInfo{ + policies.NewSetInfo("team:blue", ipsets.KeyValueLabelOfNamespace, included, matchType), + policies.NewSetInfo("tenant:x", ipsets.KeyValueLabelOfNamespace, nonIncluded, matchType), + policies.NewSetInfo("tenant:y", ipsets.KeyValueLabelOfNamespace, nonIncluded, matchType), + } + require.ElementsMatch(t, expected, nsSelectorList, + "matchLabels set must be ANDed with both negated tenant sets in one decision") +} + +// nsNotInPolicy builds a NetworkPolicy that selects all local pods and, for the given +// direction, admits peers whose namespace matches `key NotIn values`. When ports is +// non-empty, the peer rule also carries those ports. +func nsNotInPolicy(name, ns, key string, direction networkingv1.PolicyType, ports []networkingv1.NetworkPolicyPort, values ...string) *networkingv1.NetworkPolicy { + peer := networkingv1.NetworkPolicyPeer{ + NamespaceSelector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + {Key: key, Operator: metav1.LabelSelectorOpNotIn, Values: values}, + }, + }, + } + pol := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{}, + PolicyTypes: []networkingv1.PolicyType{direction}, + }, + } + if direction == networkingv1.PolicyTypeIngress { + pol.Spec.Ingress = []networkingv1.NetworkPolicyIngressRule{{Ports: ports, From: []networkingv1.NetworkPolicyPeer{peer}}} + } else { + pol.Spec.Egress = []networkingv1.NetworkPolicyEgressRule{{Ports: ports, To: []networkingv1.NetworkPolicyPeer{peer}}} + } + return pol +} + +// TestTranslatePolicyMultiValueNotInConjunction is the end-to-end regression for a +// multi-value namespaceSelector NotIn. It drives the full TranslatePolicy path (both +// directions, with and without a port) and asserts the complete enforcement invariant: +// exactly ONE allow ACL exists, it negates every excluded value within that single +// decision (a conjunction / AND) and references no positive tenant set, and a default +// drop is still present. The pre-fix behavior emitted one additive allow ACL per value, +// so a namespace carrying any one excluded value matched the ACL negating another value +// and was admitted before the default drop. +func TestTranslatePolicyMultiValueNotInConjunction(t *testing.T) { + t.Parallel() + + tcpPort := networkingv1.NetworkPolicyPort{Port: &intstr.IntOrString{Type: intstr.Int, IntVal: 80}} + + tests := []struct { + name string + direction networkingv1.PolicyType + ports []networkingv1.NetworkPolicyPort + peerList func(*policies.ACLPolicy) []policies.SetInfo + }{ + { + name: "ingress", + direction: networkingv1.PolicyTypeIngress, + peerList: func(acl *policies.ACLPolicy) []policies.SetInfo { return acl.SrcList }, + }, + { + name: "egress", + direction: networkingv1.PolicyTypeEgress, + peerList: func(acl *policies.ACLPolicy) []policies.SetInfo { return acl.DstList }, + }, + { + name: "ingress-with-port", + direction: networkingv1.PolicyTypeIngress, + ports: []networkingv1.NetworkPolicyPort{tcpPort}, + peerList: func(acl *policies.ACLPolicy) []policies.SetInfo { return acl.SrcList }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + pol := nsNotInPolicy("victim", "default", "tenant", tt.direction, tt.ports, "attacker", "quarantine") + npmNetPol, err := TranslatePolicy(pol, false) + require.NoError(t, err) + + excluded := map[string]bool{"tenant:attacker": true, "tenant:quarantine": true} + var allowACLs, dropACLs int + var theAllow, theDrop *policies.ACLPolicy + for i := range npmNetPol.ACLs { + acl := npmNetPol.ACLs[i] + switch acl.Target { + case policies.Allowed: + allowACLs++ + theAllow = npmNetPol.ACLs[i] + case policies.Dropped: + dropACLs++ + theDrop = npmNetPol.ACLs[i] + default: + t.Fatalf("unexpected ACL target %v", acl.Target) + } + } + + // Full enforcement invariant: exactly one allow decision and exactly one + // default drop. An additive-OR bypass would yield two allow ACLs; a missing + // drop or an allow-all leaking in would also be caught here. + require.Equal(t, 1, allowACLs, "there must be exactly one allow ACL, not additive allow ACLs") + require.Equal(t, 1, dropACLs, "there must be exactly one default drop ACL") + require.NotNil(t, theAllow) + require.NotNil(t, theDrop) + + // The single allow ACL's peer list must be EXACTLY the two excluded values, + // each a negated match (Included == false) and nothing else (no stray positive + // set such as an all-namespaces allow). + allowPeers := tt.peerList(theAllow) + require.Len(t, allowPeers, 2, "allow ACL must reference exactly the two excluded sets and no positive set") + var negated []string + for _, si := range allowPeers { + require.True(t, excluded[si.IPSet.Name], "unexpected set %s in allow ACL", si.IPSet.Name) + require.False(t, si.Included, "tenant set %s must be a negated match", si.IPSet.Name) + require.Equal(t, ipsets.KeyValueLabelOfNamespace, si.IPSet.Type) + negated = append(negated, si.IPSet.Name) + } + require.ElementsMatch(t, []string{"tenant:attacker", "tenant:quarantine"}, negated, + "the single allow ACL must negate every excluded value") + + // The default drop must be same-direction and unconditional (no peer match), + // so the excluded namespaces have no allow path and fall through to it. + require.Equal(t, theAllow.Direction, theDrop.Direction, "drop must be the same direction as the allow") + require.Empty(t, tt.peerList(theDrop), "the default drop must be unconditional") + + // When a port is present it must be carried in the same allow decision, + // conjunctively with the negated tenant sets. + if len(tt.ports) > 0 { + require.EqualValues(t, 80, theAllow.DstPorts.Port, + "the port must render in the same allow ACL as the negated tenant sets") + } + }) + } +} + func TestAllowAllInternal(t *testing.T) { matchType := policies.SrcMatch tests := []struct { From 1cb58e83167ed93e55a46244367d18bb6c6f105c Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Wed, 2 Sep 2026 20:32:43 +0000 Subject: [PATCH 02/33] fix: [NPM] scope negation-only namespaceSelector matches to cluster namespaces A namespaceSelector only ever selects namespaces, so every peer it matches must be a cluster address. A negative requirement (NotIn / DoesNotExist) renders as a negated set match, which is satisfied by every address that is not in that set, including addresses that are not cluster pods at all. When a namespaceSelector produced no positive set, the negations alone were the whole match, so the rule admitted non-cluster peers. On egress that let a selected pod reach arbitrary external hosts even though the policy named no ipBlock and no allow-all peer. Intersect with the all-namespaces set in parseNSSelector when the parsed selectors contain no positive match, mirroring allowAllInternal. Selectors that already carry a positive requirement (matchLabels, In, Exists) are unchanged, since that requirement already scopes the match to namespaces. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controlplane/translation/parseSelector.go | 22 ++ .../translation/translatePolicy_test.go | 212 +++++++++++++++++- 2 files changed, 229 insertions(+), 5 deletions(-) diff --git a/npm/pkg/controlplane/translation/parseSelector.go b/npm/pkg/controlplane/translation/parseSelector.go index f2e843c1e23..e26986f12d9 100644 --- a/npm/pkg/controlplane/translation/parseSelector.go +++ b/npm/pkg/controlplane/translation/parseSelector.go @@ -259,6 +259,17 @@ func (ps *parsedSelectors) addSelector(include bool, setType ipsets.SetType, set ps.labelSet[setNameWithOp] = struct{}{} } +// hasPositiveSelector reports whether any parsed selector is a positive (non-negated) match. +// Without one, the parsed selectors match purely by negation and constrain nothing. +func (ps *parsedSelectors) hasPositiveSelector() bool { + for _, ls := range ps.labelSelectors { + if ls.include { + return true + } + } + return false +} + // parseNSSelector parses namespaceSelector and returns slice of labelSelector object // which includes operator, setType, ipset name and always nil members slice. // Member slices is always nil since parseNSSelector function is called @@ -300,6 +311,17 @@ func parseNSSelector(selector *metav1.LabelSelector) []labelSelector { parsedSelectors.addSelector(noNegativeOp, setType, setName) } + // #4. A namespaceSelector only ever selects namespaces, so every match it produces + // must be a cluster address. A negative requirement (NotIn / DoesNotExist) renders as + // a negated set match, which is satisfied by every address that is not in that set, + // including addresses outside the cluster. When the selector produces no positive set + // to intersect with, the negations alone are the whole match and the rule would also + // admit non-cluster (e.g. internet) peers. Intersect with the all-namespaces set so + // the match stays scoped to namespaces, mirroring allowAllInternal. + if !parsedSelectors.hasPositiveSelector() { + parsedSelectors.addSelector(true, ipsets.KeyLabelOfNamespace, util.KubeAllNamespacesFlag) + } + return parsedSelectors.labelSelectors } diff --git a/npm/pkg/controlplane/translation/translatePolicy_test.go b/npm/pkg/controlplane/translation/translatePolicy_test.go index d0a911e5e10..e7d1de4be98 100644 --- a/npm/pkg/controlplane/translation/translatePolicy_test.go +++ b/npm/pkg/controlplane/translation/translatePolicy_test.go @@ -1293,6 +1293,8 @@ func TestNameSpaceSelectorMultiValueNotIn(t *testing.T) { _, nsSelectorList := nameSpaceSelector(matchType, &flattened[0]) expected := []policies.SetInfo{ + // The all-namespaces set keeps the negation-only match scoped to cluster namespaces. + policies.NewSetInfo(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace, included, matchType), policies.NewSetInfo("tenant:x", ipsets.KeyValueLabelOfNamespace, nonIncluded, matchType), policies.NewSetInfo("tenant:y", ipsets.KeyValueLabelOfNamespace, nonIncluded, matchType), } @@ -1328,6 +1330,196 @@ func TestNameSpaceSelectorMatchLabelsAndMultiValueNotIn(t *testing.T) { "matchLabels set must be ANDed with both negated tenant sets in one decision") } +// TestNameSpaceSelectorNegationOnlyIsScopedToNamespaces verifies that a namespaceSelector +// whose requirements are all negative (NotIn / DoesNotExist) is intersected with the +// all-namespaces set. A negated set match is satisfied by every address that is not in the +// set, so without a positive set to intersect with, the decision also matches addresses +// that are not cluster pods at all (e.g. the internet). +func TestNameSpaceSelectorNegationOnlyIsScopedToNamespaces(t *testing.T) { + matchType := policies.DstMatch + tests := []struct { + name string + selector *metav1.LabelSelector + expected []policies.SetInfo + }{ + { + name: "single-value NotIn", + selector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + {Key: "tenant", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"x"}}, + }, + }, + expected: []policies.SetInfo{ + policies.NewSetInfo(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace, included, matchType), + policies.NewSetInfo("tenant:x", ipsets.KeyValueLabelOfNamespace, nonIncluded, matchType), + }, + }, + { + name: "DoesNotExist", + selector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + {Key: "tenant", Operator: metav1.LabelSelectorOpDoesNotExist}, + }, + }, + expected: []policies.SetInfo{ + policies.NewSetInfo(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace, included, matchType), + policies.NewSetInfo("tenant", ipsets.KeyLabelOfNamespace, nonIncluded, matchType), + }, + }, + { + name: "NotIn and DoesNotExist together", + selector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + {Key: "tenant", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"x"}}, + {Key: "team", Operator: metav1.LabelSelectorOpDoesNotExist}, + }, + }, + expected: []policies.SetInfo{ + policies.NewSetInfo(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace, included, matchType), + policies.NewSetInfo("tenant:x", ipsets.KeyValueLabelOfNamespace, nonIncluded, matchType), + policies.NewSetInfo("team", ipsets.KeyLabelOfNamespace, nonIncluded, matchType), + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + nsSelectorIPSets, nsSelectorList := nameSpaceSelector(matchType, tt.selector) + require.ElementsMatch(t, tt.expected, nsSelectorList) + // The all-namespaces set must also be translated so it exists in the dataplane. + require.Contains(t, nsSelectorIPSets, + ipsets.NewTranslatedIPSet(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace)) + }) + } +} + +// TestNameSpaceSelectorWithPositiveMatchIsUnchanged verifies that the all-namespaces +// intersection is added only when it is needed. A selector that already carries a positive +// requirement is scoped to namespaces by that requirement, so it must be left as-is. +func TestNameSpaceSelectorWithPositiveMatchIsUnchanged(t *testing.T) { + matchType := policies.DstMatch + tests := []struct { + name string + selector *metav1.LabelSelector + expected []policies.SetInfo + }{ + { + name: "matchLabels only", + selector: &metav1.LabelSelector{MatchLabels: map[string]string{"team": "blue"}}, + expected: []policies.SetInfo{ + policies.NewSetInfo("team:blue", ipsets.KeyValueLabelOfNamespace, included, matchType), + }, + }, + { + name: "matchLabels with a negative expression", + selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"team": "blue"}, + MatchExpressions: []metav1.LabelSelectorRequirement{ + {Key: "tenant", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"x"}}, + }, + }, + expected: []policies.SetInfo{ + policies.NewSetInfo("team:blue", ipsets.KeyValueLabelOfNamespace, included, matchType), + policies.NewSetInfo("tenant:x", ipsets.KeyValueLabelOfNamespace, nonIncluded, matchType), + }, + }, + { + name: "Exists with a negative expression", + selector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + {Key: "team", Operator: metav1.LabelSelectorOpExists}, + {Key: "tenant", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"x"}}, + }, + }, + expected: []policies.SetInfo{ + policies.NewSetInfo("team", ipsets.KeyLabelOfNamespace, included, matchType), + policies.NewSetInfo("tenant:x", ipsets.KeyValueLabelOfNamespace, nonIncluded, matchType), + }, + }, + { + name: "empty selector still resolves to all namespaces once", + selector: &metav1.LabelSelector{}, + expected: []policies.SetInfo{ + policies.NewSetInfo(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace, included, matchType), + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + _, nsSelectorList := nameSpaceSelector(matchType, tt.selector) + require.ElementsMatch(t, tt.expected, nsSelectorList) + }) + } +} + +// TestTranslatePolicyNegationOnlyNamespaceSelector is the end-to-end regression for a +// peer whose only requirement is a negative namespaceSelector. It asserts that the +// resulting allow decision carries the all-namespaces set, so the rule cannot be +// satisfied by an address outside the cluster. Egress is the impactful direction (an +// unscoped negation lets a selected pod reach arbitrary external hosts), but ingress is +// covered too since the compiler is direction-agnostic. +func TestTranslatePolicyNegationOnlyNamespaceSelector(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + direction networkingv1.PolicyType + matchType policies.MatchType + peerList func(*policies.ACLPolicy) []policies.SetInfo + }{ + { + name: "egress", + direction: networkingv1.PolicyTypeEgress, + matchType: policies.DstMatch, + peerList: func(acl *policies.ACLPolicy) []policies.SetInfo { return acl.DstList }, + }, + { + name: "ingress", + direction: networkingv1.PolicyTypeIngress, + matchType: policies.SrcMatch, + peerList: func(acl *policies.ACLPolicy) []policies.SetInfo { return acl.SrcList }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + pol := nsNotInPolicy("victim", "default", "tenant", tt.direction, nil, "x") + npmNetPol, err := TranslatePolicy(pol, false) + require.NoError(t, err) + + var theAllow *policies.ACLPolicy + for i := range npmNetPol.ACLs { + if npmNetPol.ACLs[i].Target == policies.Allowed { + require.Nil(t, theAllow, "there must be exactly one allow ACL") + theAllow = npmNetPol.ACLs[i] + } + } + require.NotNil(t, theAllow) + + peers := tt.peerList(theAllow) + require.ElementsMatch(t, []policies.SetInfo{ + policies.NewSetInfo(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace, included, tt.matchType), + policies.NewSetInfo("tenant:x", ipsets.KeyValueLabelOfNamespace, nonIncluded, tt.matchType), + }, peers, "a negation-only namespaceSelector must be intersected with the all-namespaces set") + + var sawAllNamespaces bool + for _, si := range peers { + if si.Included && si.IPSet.Name == util.KubeAllNamespacesFlag { + sawAllNamespaces = true + } + } + require.True(t, sawAllNamespaces, + "without the all-namespaces set the negated match also admits non-cluster addresses") + }) + } +} + // nsNotInPolicy builds a NetworkPolicy that selects all local pods and, for the given // direction, admits peers whose namespace matches `key NotIn values`. When ports is // non-empty, the peer rule also carries those ports. @@ -1425,20 +1617,30 @@ func TestTranslatePolicyMultiValueNotInConjunction(t *testing.T) { require.NotNil(t, theAllow) require.NotNil(t, theDrop) - // The single allow ACL's peer list must be EXACTLY the two excluded values, - // each a negated match (Included == false) and nothing else (no stray positive - // set such as an all-namespaces allow). + // The single allow ACL's peer list must be the two excluded values, each a + // negated match (Included == false), intersected with the all-namespaces set. + // The all-namespaces set is what keeps a negation-only match scoped to cluster + // namespaces; without it the negations alone also match non-cluster addresses. allowPeers := tt.peerList(theAllow) - require.Len(t, allowPeers, 2, "allow ACL must reference exactly the two excluded sets and no positive set") + require.Len(t, allowPeers, 3, "allow ACL must reference the two excluded sets plus the all-namespaces set") var negated []string + var positive []string for _, si := range allowPeers { + if si.Included { + require.Equal(t, util.KubeAllNamespacesFlag, si.IPSet.Name, + "the only positive set may be the all-namespaces set") + require.Equal(t, ipsets.KeyLabelOfNamespace, si.IPSet.Type) + positive = append(positive, si.IPSet.Name) + continue + } require.True(t, excluded[si.IPSet.Name], "unexpected set %s in allow ACL", si.IPSet.Name) - require.False(t, si.Included, "tenant set %s must be a negated match", si.IPSet.Name) require.Equal(t, ipsets.KeyValueLabelOfNamespace, si.IPSet.Type) negated = append(negated, si.IPSet.Name) } require.ElementsMatch(t, []string{"tenant:attacker", "tenant:quarantine"}, negated, "the single allow ACL must negate every excluded value") + require.Equal(t, []string{util.KubeAllNamespacesFlag}, positive, + "the negation-only match must be intersected with the all-namespaces set") // The default drop must be same-direction and unconditional (no peer match), // so the excluded namespaces have no allow path and fall through to it. From f691b00fbb730c6ef07e186797535e7b7c81e694 Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Wed, 2 Sep 2026 20:36:12 +0000 Subject: [PATCH 03/33] fix: [NPM] bound namespaceSelector flattening Flattening a namespaceSelector's multi-value In requirements produces the Cartesian product of their values, and the code had no ceiling on the result. The count is the product of the value counts, so it grows exponentially with the number of such requirements: 19 two-value requirements in one small, valid policy expand to 2^19 selectors. Each one is deep-copied and later becomes its own IPSet and ACL, so a single policy object could exhaust the memory of the NPM DaemonSet on every node and take policy programming down cluster-wide. Compute the product before any allocation and reject the selector once it would exceed maxFlattenedNSSelectors. The check divides instead of multiplying so it cannot overflow, and it runs per requirement, so a single very wide requirement is rejected on the first iteration too. The cap is far above any workable policy since a selector fanning out that wide would already be unusable as rules. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controlplane/translation/parseSelector.go | 26 +++++- .../translation/parseSelector_test.go | 80 +++++++++++++++++++ .../translation/translatePolicy.go | 4 + 3 files changed, 107 insertions(+), 3 deletions(-) diff --git a/npm/pkg/controlplane/translation/parseSelector.go b/npm/pkg/controlplane/translation/parseSelector.go index e26986f12d9..6a3d305e76d 100644 --- a/npm/pkg/controlplane/translation/parseSelector.go +++ b/npm/pkg/controlplane/translation/parseSelector.go @@ -15,6 +15,14 @@ import ( // an alphanumeric character (e.g. 'MyValue', or 'my_value', or '12345', regex used for validation is '(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?' var validLabelRegex = regexp.MustCompile("(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?") +// maxFlattenedNSSelectors caps how many labelSelectors a single namespaceSelector may be +// flattened into. Flattening multi-value In requirements produces the Cartesian product of +// their values, and each resulting selector is deep-copied and later turned into its own +// IPSet and ACL, so the cost grows exponentially with the number of such requirements. The +// cap is far above any workable policy (a selector fanning out this wide would already be +// unusable as iptables rules) while keeping a crafted selector from exhausting memory. +const maxFlattenedNSSelectors = 1000 + // flattenNameSpaceSelector will help flatten multiple nameSpace selector match Expressions values // into multiple label selectors helping with the OR condition. func flattenNameSpaceSelector(nsSelector *metav1.LabelSelector) ([]metav1.LabelSelector, error) { @@ -167,10 +175,22 @@ func flattenNameSpaceSelector(nsSelector *metav1.LabelSelector) ([]metav1.LabelS } // Now use the baseSelector and loop over multiValueMatchExprs to create all - // combinations of values - flatNsSelectors := []metav1.LabelSelector{ - *baseSelector.DeepCopy(), + // combinations of values. The number of combinations is the product of the value + // counts, so it grows exponentially with the number of multi-value In requirements + // (19 two-value requirements already yield 2^19 selectors). Bound the product before + // doing any allocation: every selector below is deep-copied and later becomes its own + // IPSet and ACL, so an unbounded product exhausts memory on every node running NPM. + combinations := 1 + for _, req := range multiValueMatchExprs { + if len(req.Values) > maxFlattenedNSSelectors/combinations { + log.Errorf("namespaceSelector [%v] expands past the %d selector limit", *nsSelector, maxFlattenedNSSelectors) + return nil, ErrTooManyFlattenedSelectors + } + combinations *= len(req.Values) } + + flatNsSelectors := make([]metav1.LabelSelector, 0, combinations) + flatNsSelectors = append(flatNsSelectors, *baseSelector.DeepCopy()) for _, req := range multiValueMatchExprs { flatNsSelectors = zipMatchExprs(flatNsSelectors, req) } diff --git a/npm/pkg/controlplane/translation/parseSelector_test.go b/npm/pkg/controlplane/translation/parseSelector_test.go index 45357af8644..f2541d7913e 100644 --- a/npm/pkg/controlplane/translation/parseSelector_test.go +++ b/npm/pkg/controlplane/translation/parseSelector_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/stretchr/testify/require" + networkingv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -721,6 +722,85 @@ func TestFlattenNameSpaceSelectorEmptyValues(t *testing.T) { } } +// TestFlattenNameSpaceSelectorExpansionLimit verifies that a namespaceSelector whose +// multi-value In requirements would expand into more selectors than NPM is willing to +// translate is rejected before any allocation. Each flattened selector is deep-copied and +// later becomes its own IPSet and ACL, and the count is the product of the value counts, +// so an unbounded selector exhausts memory on every node running NPM. +func TestFlattenNameSpaceSelectorExpansionLimit(t *testing.T) { + twoValueReqs := func(n int) []metav1.LabelSelectorRequirement { + reqs := make([]metav1.LabelSelectorRequirement, 0, n) + for i := 0; i < n; i++ { + reqs = append(reqs, metav1.LabelSelectorRequirement{ + Key: fmt.Sprintf("key%d", i), + Operator: metav1.LabelSelectorOpIn, + Values: []string{"a", "b"}, + }) + } + return reqs + } + + // 2^9 = 512 selectors is under the limit and must still translate. + under := &metav1.LabelSelector{MatchExpressions: twoValueReqs(9)} + selectors, err := flattenNameSpaceSelector(under) + require.NoError(t, err) + require.Len(t, selectors, 512) + + // 2^19 = 524288 selectors is the reported exhaustion case and must be rejected. + over := &metav1.LabelSelector{MatchExpressions: twoValueReqs(19)} + selectors, err = flattenNameSpaceSelector(over) + require.ErrorIs(t, err, ErrTooManyFlattenedSelectors) + require.Nil(t, selectors) + + // A single requirement wider than the limit is rejected on the first iteration, + // so the guard cannot be sidestepped by using one very wide requirement. + values := make([]string, maxFlattenedNSSelectors+1) + for i := range values { + values[i] = fmt.Sprintf("v%d", i) + } + wide := &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + {Key: "key", Operator: metav1.LabelSelectorOpIn, Values: values}, + }, + } + selectors, err = flattenNameSpaceSelector(wide) + require.ErrorIs(t, err, ErrTooManyFlattenedSelectors) + require.Nil(t, selectors) +} + +// TestTranslatePolicyExpansionLimit verifies the expansion guard surfaces through the full +// translation path rather than being swallowed, so an oversized policy is rejected instead +// of being expanded. +func TestTranslatePolicyExpansionLimit(t *testing.T) { + reqs := make([]metav1.LabelSelectorRequirement, 0, 19) + for i := 0; i < 19; i++ { + reqs = append(reqs, metav1.LabelSelectorRequirement{ + Key: fmt.Sprintf("key%d", i), + Operator: metav1.LabelSelectorOpIn, + Values: []string{"a", "b"}, + }) + } + + pol := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "expand", Namespace: "default"}, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{}, + PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress}, + Ingress: []networkingv1.NetworkPolicyIngressRule{ + { + From: []networkingv1.NetworkPolicyPeer{ + {NamespaceSelector: &metav1.LabelSelector{MatchExpressions: reqs}}, + }, + }, + }, + }, + } + + npmNetPol, err := TranslatePolicy(pol, false) + require.ErrorIs(t, err, ErrTooManyFlattenedSelectors) + require.Nil(t, npmNetPol) +} + func TestIsValidLabel(t *testing.T) { good := []string{ "", diff --git a/npm/pkg/controlplane/translation/translatePolicy.go b/npm/pkg/controlplane/translation/translatePolicy.go index 777263d2381..cb85f475be4 100644 --- a/npm/pkg/controlplane/translation/translatePolicy.go +++ b/npm/pkg/controlplane/translation/translatePolicy.go @@ -43,6 +43,10 @@ var ( // none of In, NotIn, Exists or DoesNotExist. NPM fails closed rather than dropping the requirement, // which could otherwise silently widen the selector. ErrUnsupportedMatchExpressionOperator = errors.New("unsupported matchExpression operator") + // ErrTooManyFlattenedSelectors is returned when flattening a namespaceSelector's multi-value In + // requirements would produce more labelSelectors than NPM is willing to translate. The count is + // the product of the value counts, so it grows exponentially with the number of such requirements. + ErrTooManyFlattenedSelectors = errors.New("namespaceSelector expands into too many label selectors") // ErrUnsupportedIPAddress is returned when an unsupported IP address, such as IPV6, is used ErrUnsupportedIPAddress = errors.New("unsupported IP address") // ErrUnsupportedNonCIDR is returned when non-CIDR blocks are passed in with NPM Lite enabled. NPM Lite allows deny-all and allow-all policies From 68b9c34e5d0667142c38414f4f4edd3a9b66f2be Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Wed, 2 Sep 2026 20:46:27 +0000 Subject: [PATCH 04/33] fix: [NPM] canonicalize ipBlock CIDRs and stop reporting failed translation as success An ipBlock CIDR that named the all-addresses block with host bits set, such as 10.0.0.0/0, was rejected before the value was canonicalized, even though it denotes exactly the same addresses as 0.0.0.0/0. That failed the translation of the whole policy, and the v2 controller converted the failure into a successful no-op, so neither the peer rule nor the default drop the policy implies was installed. The policy looked applied while its selected pods were left with no rules at all and nothing signalled the failure. Canonicalize instead of rejecting. NormalizeCIDR clears the host bits, IsIPV4 validates through it, and the ipBlock translation compares and emits the canonical form, so a non-canonical spelling takes the same path as the canonical one. Except CIDRs are canonicalized too, so they de-duplicate correctly and are recognized by the all-addresses split rather than naming the same block twice with opposite meanings. Kernel ipset members must still be canonical, so the dataplane member check now requires that explicitly rather than relying on the old textual rejection. In the controller, a translation failure is now returned and recorded instead of reported as success, so it is visible and the key is requeued. Deliberate datapath limitations (the Windows unsupported features and NPM Lite's CIDR-only peers) cannot resolve on retry and stay suppressed with a warning as before. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controllers/v2/networkPolicyController.go | 29 +++++- .../v2/networkPolicyController_test.go | 99 +++++++++++++++++++ .../translation/translatePolicy.go | 30 ++++-- .../translation/translatePolicy_test.go | 81 ++++++++++++++- npm/util/util.go | 32 ++++-- npm/util/util_test.go | 61 ++++++++++++ 6 files changed, 309 insertions(+), 23 deletions(-) diff --git a/npm/pkg/controlplane/controllers/v2/networkPolicyController.go b/npm/pkg/controlplane/controllers/v2/networkPolicyController.go index 31ed83b5067..60442ac654d 100644 --- a/npm/pkg/controlplane/controllers/v2/networkPolicyController.go +++ b/npm/pkg/controlplane/controllers/v2/networkPolicyController.go @@ -291,8 +291,8 @@ func (c *NetworkPolicyController) syncAddAndUpdateNetPol(netPolObj *networkingv1 // install translated rules into kernel npmNetPolObj, err := translation.TranslatePolicy(netPolObj, c.npmLiteToggle) if err != nil { - if isUnsupportedWindowsTranslationErr(err) { - klog.Warningf("NetworkPolicy %s in namespace %s is not translated because it has unsupported translated features of Windows: %s", + if isUnsupportedTranslationErr(err) { + klog.Warningf("NetworkPolicy %s in namespace %s is not translated because it uses a feature this datapath does not support: %s", netPolObj.ObjectMeta.Name, netPolObj.ObjectMeta.Namespace, err.Error()) // We can safely suppress unsupported network policy because re-Queuing will result in same error. @@ -300,9 +300,17 @@ func (c *NetworkPolicyController) syncAddAndUpdateNetPol(netPolObj *networkingv1 return metrics.NoOp, nil } - klog.Errorf("Failed to translate podSelector in NetworkPolicy %s in namespace %s: %s", netPolObj.ObjectMeta.Name, netPolObj.ObjectMeta.Namespace, err.Error()) - // The exec time isn't relevant here, so consider a no-op. Returning nil to prevent re-queuing since this is not a transient error. - return metrics.NoOp, nil + klog.Errorf("Failed to translate NetworkPolicy %s in namespace %s: %s", netPolObj.ObjectMeta.Name, netPolObj.ObjectMeta.Namespace, err.Error()) + metrics.SendErrorLogAndMetric(util.NetpolID, + "[syncAddAndUpdateNetPol] Error: failed to translate NetworkPolicy %s in namespace %s due to %v", + netPolObj.ObjectMeta.Name, netPolObj.ObjectMeta.Namespace, err) + // Do not report success here. Reporting success left the policy's selected pods with + // no rules at all - not even the default drop the policy implies - while the policy + // object appeared to be applied and nothing signalled the failure. Return the error so + // it is surfaced and the key is requeued (rate limited) instead. + // The exec time isn't relevant here, so consider a no-op. + return metrics.NoOp, fmt.Errorf("[syncAddAndUpdateNetPol] Error: failed to translate NetworkPolicy %s in namespace %s: %w", + netPolObj.ObjectMeta.Name, netPolObj.ObjectMeta.Namespace, err) } _, policyExisted := c.rawNpSpecMap[netpolKey] @@ -358,3 +366,14 @@ func isUnsupportedWindowsTranslationErr(err error) bool { errors.Is(err, translation.ErrUnsupportedSCTP) || errors.Is(err, translation.ErrUnsupportedExceptCIDR) } + +// isUnsupportedTranslationErr reports whether err is a deliberate limitation of the datapath +// or mode NPM is running in, rather than a policy NPM failed to translate. Those limitations +// cannot resolve on retry, so they stay suppressed with a warning. Every other translation +// failure is surfaced and requeued, because reporting success would leave the policy's +// selected pods with no rules while nothing signalled that the policy was never applied. +func isUnsupportedTranslationErr(err error) bool { + return isUnsupportedWindowsTranslationErr(err) || + // NPM Lite only supports CIDR peers; a label-selector peer is out of scope there. + errors.Is(err, translation.ErrUnsupportedNonCIDR) +} diff --git a/npm/pkg/controlplane/controllers/v2/networkPolicyController_test.go b/npm/pkg/controlplane/controllers/v2/networkPolicyController_test.go index d14f6f67f12..29aa44f8cbd 100644 --- a/npm/pkg/controlplane/controllers/v2/networkPolicyController_test.go +++ b/npm/pkg/controlplane/controllers/v2/networkPolicyController_test.go @@ -9,6 +9,7 @@ import ( "github.com/Azure/azure-container-networking/npm/metrics" "github.com/Azure/azure-container-networking/npm/metrics/promutil" + "github.com/Azure/azure-container-networking/npm/pkg/controlplane/translation" "github.com/Azure/azure-container-networking/npm/pkg/dataplane" dpmocks "github.com/Azure/azure-container-networking/npm/pkg/dataplane/mocks" "github.com/Azure/azure-container-networking/npm/util" @@ -618,3 +619,101 @@ func TestLabelUpdateNetworkPolicy(t *testing.T) { checkNetPolTestResult("TestUpdateNetPol", f, testCases) } + +// netPolWithCIDR builds an ingress NetworkPolicy that selects all pods in its namespace and +// admits the given ipBlock CIDR. +func netPolWithCIDR(cidr string) *networkingv1.NetworkPolicy { + return &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "allow-cidr", Namespace: "test-nwpolicy"}, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{}, + PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress}, + Ingress: []networkingv1.NetworkPolicyIngressRule{ + {From: []networkingv1.NetworkPolicyPeer{{IPBlock: &networkingv1.IPBlock{CIDR: cidr}}}}, + }, + }, + } +} + +// TestAddNetworkPolicyNonCanonicalCIDRIsApplied verifies that a policy naming the +// all-addresses block with host bits set is programmed into the dataplane. It used to fail +// translation, and the controller turned that failure into a successful no-op, so the +// policy's selected pods were left with no rules at all. +func TestAddNetworkPolicyNonCanonicalCIDRIsApplied(t *testing.T) { + netPolObj := netPolWithCIDR("10.0.0.0/0") + + f := newNetPolFixture(t) + f.netPolLister = append(f.netPolLister, netPolObj) + f.kubeobjects = append(f.kubeobjects, netPolObj) + stopCh := make(chan struct{}) + defer close(stopCh) + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + dp := dpmocks.NewMockGenericDataplane(ctrl) + f.newNetPolController(stopCh, dp, false) + + // The policy must reach the dataplane instead of being dropped during translation. + dp.EXPECT().UpdatePolicy(gomock.Any()).Times(1) + + addNetPol(f, netPolObj) + checkNetPolTestResult("TestAddNetworkPolicyNonCanonicalCIDRIsApplied", f, []expectedNetPolValues{ + {1, 0, netPolPromVals{1, 1, 0, 0}}, + }) +} + +// TestSyncAddAndUpdateNetPolSurfacesTranslationFailure verifies that a policy NPM cannot +// translate is reported as an error rather than as a successful no-op. Reporting success +// left the policy's selected pods with no rules while nothing signalled that the policy had +// never been applied. +func TestSyncAddAndUpdateNetPolSurfacesTranslationFailure(t *testing.T) { + // An IPv6 ipBlock cannot be expressed by the IPv4 datapath, so translation fails. + netPolObj := netPolWithCIDR("2001:db8::/32") + + f := newNetPolFixture(t) + f.netPolLister = append(f.netPolLister, netPolObj) + f.kubeobjects = append(f.kubeobjects, netPolObj) + stopCh := make(chan struct{}) + defer close(stopCh) + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + dp := dpmocks.NewMockGenericDataplane(ctrl) + f.newNetPolController(stopCh, dp, false) + + // Nothing may be programmed for a policy that failed to translate. + dp.EXPECT().UpdatePolicy(gomock.Any()).Times(0) + + _, err := f.netPolController.syncAddAndUpdateNetPol(netPolObj) + require.Error(t, err, "a translation failure must be surfaced, not reported as success") + require.ErrorIs(t, err, translation.ErrUnsupportedIPAddress) + + // The policy must not be recorded as applied, so a later retry still reconciles it. + netpolKey, keyErr := cache.MetaNamespaceKeyFunc(netPolObj) + require.NoError(t, keyErr) + require.NotContains(t, f.netPolController.rawNpSpecMap, netpolKey) +} + +// TestSyncAddAndUpdateNetPolSuppressesUnsupportedFeature verifies that a deliberate datapath +// limitation stays suppressed. Those cannot resolve on retry, so requeuing them forever +// would be pure churn. +func TestSyncAddAndUpdateNetPolSuppressesUnsupportedFeature(t *testing.T) { + // NPM Lite only supports CIDR peers, so a label-selector peer is out of scope there. + netPolObj := createNetPol() + + f := newNetPolFixture(t) + f.netPolLister = append(f.netPolLister, netPolObj) + f.kubeobjects = append(f.kubeobjects, netPolObj) + stopCh := make(chan struct{}) + defer close(stopCh) + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + dp := dpmocks.NewMockGenericDataplane(ctrl) + f.newNetPolController(stopCh, dp, true) + + dp.EXPECT().UpdatePolicy(gomock.Any()).Times(0) + + _, err := f.netPolController.syncAddAndUpdateNetPol(netPolObj) + require.NoError(t, err, "an unsupported-feature limitation must stay suppressed") +} diff --git a/npm/pkg/controlplane/translation/translatePolicy.go b/npm/pkg/controlplane/translation/translatePolicy.go index cb85f475be4..b0b0641095a 100644 --- a/npm/pkg/controlplane/translation/translatePolicy.go +++ b/npm/pkg/controlplane/translation/translatePolicy.go @@ -167,14 +167,23 @@ func exceptCidr(exceptCidr string) string { return exceptCidr + " " + util.IpsetNomatch } -// deDuplicateExcept removes redundance elements and return slices which has only unique element. +// deDuplicateExcept canonicalizes each except CIDR and removes redundant elements, returning +// a slice which has only unique elements. Canonicalizing first means two spellings of the same +// block (e.g. "10.1.2.0/24" and "10.1.2.3/24") collapse to one entry and that the result can be +// compared against the split-CIDR entries below. func deDuplicateExcept(exceptInIPBlock []string) []string { deDupExcepts := []string{} exceptsSet := make(map[string]struct{}) for _, except := range exceptInIPBlock { - if _, exist := exceptsSet[except]; !exist { - deDupExcepts = append(deDupExcepts, except) - exceptsSet[except] = struct{}{} + canonical, ok := util.NormalizeCIDR(except) + if !ok { + // Leave a non-IPv4 except untouched; callers validate it separately and + // fail closed rather than silently dropping the exclusion. + canonical = except + } + if _, exist := exceptsSet[canonical]; !exist { + deDupExcepts = append(deDupExcepts, canonical) + exceptsSet[canonical] = struct{}{} } } return deDupExcepts @@ -186,6 +195,15 @@ func ipBlockIPSet(policyName, ns string, direction policies.Direction, ipBlockSe return nil, nil } + // Canonicalize the CIDR before it is compared or handed to the kernel. A block spelled + // with host bits set (e.g. "10.0.0.0/0") denotes the same addresses as its canonical form + // but does not compare equal to it, so without this the all-addresses block below would + // not be recognized and the literal would be rejected by ipset. + cidr, ok := util.NormalizeCIDR(ipBlockRule.CIDR) + if !ok { + return nil, ErrUnsupportedIPAddress + } + // de-duplicated Except if there are redundance elements. deDupExcepts := deDuplicateExcept(ipBlockRule.Except) lenOfDeDupExcepts := len(deDupExcepts) @@ -202,7 +220,7 @@ func ipBlockIPSet(policyName, ns string, direction policies.Direction, ipBlockSe // splitCIDRSet has two entries ("0.0.0.0/1" and "128.0.0.0/1") as key. splitCIDRLen := 2 splitCIDRSet := make(map[string]int, splitCIDRLen) - if ipBlockRule.CIDR == "0.0.0.0/0" { + if cidr == "0.0.0.0/0" { // two cidrs (0.0.0.0/1 and 128.0.0.0/1) for 0.0.0.0/0 + except. members = make([]string, lenOfDeDupExcepts+splitCIDRLen) // in case of "0.0.0.0/0", "0.0.0.0/1" or "0.0.0.0/1 nomatch" comes eariler than "128.0.0.0/1" or "128.0.0.0/1 nomatch". @@ -215,7 +233,7 @@ func ipBlockIPSet(policyName, ns string, direction policies.Direction, ipBlockSe } else { // one cidr + except members = make([]string, lenOfDeDupExcepts+1) - members[indexOfMembers] = ipBlockRule.CIDR + members[indexOfMembers] = cidr indexOfMembers++ } diff --git a/npm/pkg/controlplane/translation/translatePolicy_test.go b/npm/pkg/controlplane/translation/translatePolicy_test.go index e7d1de4be98..5531d7dcd26 100644 --- a/npm/pkg/controlplane/translation/translatePolicy_test.go +++ b/npm/pkg/controlplane/translation/translatePolicy_test.go @@ -636,13 +636,18 @@ func TestIPBlockIPSet(t *testing.T) { translatedIPSet: ipsets.NewTranslatedIPSet("test:in-ns:default-0-0IN", ipsets.CIDRBlocks, []string{"0.0.0.0/1", "128.0.0.0/1"}...), }, { - name: "cidr: 0.0.0.0/0 and except: 10.0.0.0/1", + // "10.0.0.0/1" is a non-canonical spelling of the block "0.0.0.0/1", so this + // except names the lower half that the 0.0.0.0/0 split already emits. It must + // therefore collapse onto that entry as a nomatch, exactly as the canonical + // "0.0.0.0/1" case below does. Emitting "0.0.0.0/1" alongside a separate + // "10.0.0.0/1 nomatch" would name the same net twice with opposite meanings. + name: "cidr: 0.0.0.0/0 and except: 10.0.0.0/1 (non-canonical 0.0.0.0/1)", ipBlockInfo: createIPBlockInfo("test", defaultNS, policies.Ingress, policies.SrcMatch, 0, 0), ipBlockRule: &networkingv1.IPBlock{ CIDR: "0.0.0.0/0", Except: []string{"10.0.0.0/1"}, }, - translatedIPSet: ipsets.NewTranslatedIPSet("test:in-ns:default-0-0IN", ipsets.CIDRBlocks, []string{"0.0.0.0/1", "128.0.0.0/1", "10.0.0.0/1 nomatch"}...), + translatedIPSet: ipsets.NewTranslatedIPSet("test:in-ns:default-0-0IN", ipsets.CIDRBlocks, []string{"0.0.0.0/1 nomatch", "128.0.0.0/1"}...), skipWindows: true, }, { @@ -3863,3 +3868,75 @@ func TestTranslatePolicyNodeEgressPorts(t *testing.T) { require.NoError(t, err) require.Equal(t, []int32{5005, 2500}, npmNetPol.NodeEgressPorts) } + +// ipBlockPolicy builds an ingress NetworkPolicy that selects all pods in ns and admits the +// given ipBlock CIDR. +func ipBlockPolicy(name, ns, cidr string) *networkingv1.NetworkPolicy { + return &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{}, + PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress}, + Ingress: []networkingv1.NetworkPolicyIngressRule{ + { + From: []networkingv1.NetworkPolicyPeer{ + {IPBlock: &networkingv1.IPBlock{CIDR: cidr}}, + }, + }, + }, + }, + } +} + +// TestTranslatePolicyNonCanonicalAllAddressesCIDR verifies that an ipBlock naming the +// all-addresses block with host bits set (e.g. "10.0.0.0/0") translates identically to the +// canonical "0.0.0.0/0". Rejecting it failed the whole policy, so neither the allow nor the +// default drop the policy implies was installed and the selected pods stayed unisolated. +func TestTranslatePolicyNonCanonicalAllAddressesCIDR(t *testing.T) { + t.Parallel() + + canonical, err := TranslatePolicy(ipBlockPolicy("victim", "default", "0.0.0.0/0"), false) + require.NoError(t, err) + + for _, cidr := range []string{"10.0.0.0/0", "255.255.255.255/0"} { + cidr := cidr + t.Run(cidr, func(t *testing.T) { + t.Parallel() + + npmNetPol, err := TranslatePolicy(ipBlockPolicy("victim", "default", cidr), false) + require.NoError(t, err, "a non-canonical all-addresses block must not fail translation") + require.NotNil(t, npmNetPol) + + // The policy must be indistinguishable from the canonical spelling: same + // ipset members (the 0.0.0.0/0 split) and the same ACLs. + require.Equal(t, canonical.RuleIPSets, npmNetPol.RuleIPSets) + require.Equal(t, len(canonical.ACLs), len(npmNetPol.ACLs)) + + // Most importantly the default drop must exist, since its absence is what + // left the selected pods unisolated. + var dropACLs int + for i := range npmNetPol.ACLs { + if npmNetPol.ACLs[i].Target == policies.Dropped { + dropACLs++ + } + } + require.Equal(t, 1, dropACLs, "the policy's default drop must be installed") + }) + } +} + +// TestTranslatePolicyInvalidCIDRStillFails verifies the canonicalization did not weaken +// validation: a CIDR that is not IPv4 at all must still be rejected. +func TestTranslatePolicyInvalidCIDRStillFails(t *testing.T) { + t.Parallel() + + for _, cidr := range []string{"2001:db8::/32", "10.0.0.0/33", "not-a-cidr/0"} { + cidr := cidr + t.Run(cidr, func(t *testing.T) { + t.Parallel() + npmNetPol, err := TranslatePolicy(ipBlockPolicy("victim", "default", cidr), false) + require.ErrorIs(t, err, ErrUnsupportedIPAddress) + require.Nil(t, npmNetPol) + }) + } +} diff --git a/npm/util/util.go b/npm/util/util.go index daefd4d1b4c..b9edc4b6ee3 100644 --- a/npm/util/util.go +++ b/npm/util/util.go @@ -363,23 +363,35 @@ func SliceToString(list []string) string { return strings.Join(list, SetPolicyDelimiter) } +// NormalizeCIDR returns the canonical form of an IPv4 CIDR, i.e. the block with its host +// bits cleared, so "10.0.0.0/0" becomes "0.0.0.0/0" and "10.1.2.3/24" becomes "10.1.2.0/24". +// It reports false when s is not an IPv4 CIDR. Callers must normalize before comparing a +// CIDR against a well-known block or handing it to the kernel, because a non-canonical +// spelling denotes the same block but does not compare equal and is not accepted by ipset. +func NormalizeCIDR(s string) (string, bool) { + _, network, err := net.ParseCIDR(s) + if err != nil || network.IP.To4() == nil || len(network.Mask) != net.IPv4len { + return "", false + } + return network.String(), true +} + +// IsIPV4 returns true when ip is an IPv4 address or an IPv4 CIDR block. +// A CIDR is validated through NormalizeCIDR, which canonicalizes it first, so a block that +// is spelled with host bits set (for example "10.0.0.0/0") is recognized as the block it +// denotes instead of being rejected. Rejecting such a block would fail the whole policy +// translation and leave the policy's selected pods with no rules at all. func IsIPV4(ip string) bool { - isIPBlock := strings.Contains(ip, "/") - ipOnly := strings.Split(ip, "/") - if strings.Contains(ip, "/0") && ipOnly[0] != "0.0.0.0" { - return false + if strings.Contains(ip, "/") { + _, ok := NormalizeCIDR(ip) + return ok } - address, err := netip.ParseAddr(ipOnly[0]) + address, err := netip.ParseAddr(ip) if err != nil { return false } - if address.Is4() && isIPBlock { - _, _, err := net.ParseCIDR(ip) - return err == nil - } - return address.Is4() } diff --git a/npm/util/util_test.go b/npm/util/util_test.go index af671eabd10..a6cb83ceb82 100644 --- a/npm/util/util_test.go +++ b/npm/util/util_test.go @@ -514,3 +514,64 @@ func TestHashedNameGoldenVectors(t *testing.T) { require.Equal(t, want, GetHashedChainName(in), "GetHashedChainName(%q) golden vector", in) } } + +// TestIsIPV4 covers address and CIDR forms. The CIDR cases matter most: a block whose host +// bits are set (e.g. "10.0.0.0/0") denotes the same addresses as its canonical form and must +// be accepted, because rejecting it fails the whole policy translation and leaves the +// policy's selected pods with no rules at all. +func TestIsIPV4(t *testing.T) { + valid := []string{ + "10.0.0.1", + "0.0.0.0", + "10.0.0.0/24", + "0.0.0.0/0", + // non-canonical spellings of valid IPv4 blocks + "10.0.0.0/0", + "255.255.255.255/0", + "10.1.2.3/24", + "10.0.0.1/32", + } + for _, ip := range valid { + require.True(t, IsIPV4(ip), "IsIPV4(%q) must be true", ip) + } + + invalid := []string{ + "", + "not-an-ip", + "10.0.0.256", + "10.0.0.0/33", + "10.0.0.0/", + "2001:db8::1", + "2001:db8::/32", + "::/0", + } + for _, ip := range invalid { + require.False(t, IsIPV4(ip), "IsIPV4(%q) must be false", ip) + } +} + +// TestNormalizeCIDR verifies that host bits are cleared, so callers can compare a CIDR +// against a well-known block and hand the canonical form to the kernel. +func TestNormalizeCIDR(t *testing.T) { + canonical := map[string]string{ + "0.0.0.0/0": "0.0.0.0/0", + "10.0.0.0/0": "0.0.0.0/0", + "255.255.255.255/0": "0.0.0.0/0", + "10.0.0.0/1": "0.0.0.0/1", + "200.0.0.0/1": "128.0.0.0/1", + "10.1.2.3/24": "10.1.2.0/24", + "10.1.2.0/24": "10.1.2.0/24", + "10.0.0.1/32": "10.0.0.1/32", + } + for in, want := range canonical { + got, ok := NormalizeCIDR(in) + require.True(t, ok, "NormalizeCIDR(%q) must succeed", in) + require.Equal(t, want, got, "NormalizeCIDR(%q)", in) + } + + for _, in := range []string{"", "10.0.0.1", "not-a-cidr", "10.0.0.0/33", "2001:db8::/32", "::/0"} { + got, ok := NormalizeCIDR(in) + require.False(t, ok, "NormalizeCIDR(%q) must fail", in) + require.Empty(t, got) + } +} From affea4231ed486296c93c26bdde429ae4d9708c4 Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Wed, 2 Sep 2026 21:36:30 +0000 Subject: [PATCH 05/33] chore: [NPM] address lint findings in the changed files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controllers/v2/networkPolicyController.go | 6 +++--- npm/pkg/controlplane/translation/parseSelector_test.go | 2 ++ npm/pkg/controlplane/translation/translatePolicy.go | 2 +- npm/pkg/controlplane/translation/translatePolicy_test.go | 8 +------- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/npm/pkg/controlplane/controllers/v2/networkPolicyController.go b/npm/pkg/controlplane/controllers/v2/networkPolicyController.go index 60442ac654d..5ccae8b7a7a 100644 --- a/npm/pkg/controlplane/controllers/v2/networkPolicyController.go +++ b/npm/pkg/controlplane/controllers/v2/networkPolicyController.go @@ -300,17 +300,17 @@ func (c *NetworkPolicyController) syncAddAndUpdateNetPol(netPolObj *networkingv1 return metrics.NoOp, nil } - klog.Errorf("Failed to translate NetworkPolicy %s in namespace %s: %s", netPolObj.ObjectMeta.Name, netPolObj.ObjectMeta.Namespace, err.Error()) + klog.Errorf("Failed to translate NetworkPolicy %s in namespace %s: %s", netPolObj.Name, netPolObj.Namespace, err.Error()) metrics.SendErrorLogAndMetric(util.NetpolID, "[syncAddAndUpdateNetPol] Error: failed to translate NetworkPolicy %s in namespace %s due to %v", - netPolObj.ObjectMeta.Name, netPolObj.ObjectMeta.Namespace, err) + netPolObj.Name, netPolObj.Namespace, err) // Do not report success here. Reporting success left the policy's selected pods with // no rules at all - not even the default drop the policy implies - while the policy // object appeared to be applied and nothing signalled the failure. Return the error so // it is surfaced and the key is requeued (rate limited) instead. // The exec time isn't relevant here, so consider a no-op. return metrics.NoOp, fmt.Errorf("[syncAddAndUpdateNetPol] Error: failed to translate NetworkPolicy %s in namespace %s: %w", - netPolObj.ObjectMeta.Name, netPolObj.ObjectMeta.Namespace, err) + netPolObj.Name, netPolObj.Namespace, err) } _, policyExisted := c.rawNpSpecMap[netpolKey] diff --git a/npm/pkg/controlplane/translation/parseSelector_test.go b/npm/pkg/controlplane/translation/parseSelector_test.go index f2541d7913e..1ec6e47fc0d 100644 --- a/npm/pkg/controlplane/translation/parseSelector_test.go +++ b/npm/pkg/controlplane/translation/parseSelector_test.go @@ -675,6 +675,8 @@ func TestFlattenNameSpaceSelectorMixedInAndNotIn(t *testing.T) { case metav1.LabelSelectorOpIn: require.Equal(t, "role", req.Key) inValues = append(inValues, req.Values[0]) + case metav1.LabelSelectorOpExists, metav1.LabelSelectorOpDoesNotExist: + t.Fatalf("unexpected valueless operator %s", req.Operator) default: t.Fatalf("unexpected operator %s", req.Operator) } diff --git a/npm/pkg/controlplane/translation/translatePolicy.go b/npm/pkg/controlplane/translation/translatePolicy.go index b0b0641095a..e67b0e72bf3 100644 --- a/npm/pkg/controlplane/translation/translatePolicy.go +++ b/npm/pkg/controlplane/translation/translatePolicy.go @@ -38,7 +38,7 @@ var ( // ErrEmptyMatchExpressionValues is returned when an In or NotIn matchExpression carries no values. // Kubernetes rejects such requirements; NPM fails closed rather than dropping the requirement, // which could otherwise widen a selector (e.g. a dropped NotIn) or yield no rules at all. - ErrEmptyMatchExpressionValues = errors.New("In and NotIn matchExpression requirements must have at least one value") + ErrEmptyMatchExpressionValues = errors.New("in and notIn matchExpression requirements must have at least one value") // ErrUnsupportedMatchExpressionOperator is returned when a matchExpression uses an operator that is // none of In, NotIn, Exists or DoesNotExist. NPM fails closed rather than dropping the requirement, // which could otherwise silently widen the selector. diff --git a/npm/pkg/controlplane/translation/translatePolicy_test.go b/npm/pkg/controlplane/translation/translatePolicy_test.go index 5531d7dcd26..9f530770fe1 100644 --- a/npm/pkg/controlplane/translation/translatePolicy_test.go +++ b/npm/pkg/controlplane/translation/translatePolicy_test.go @@ -1388,7 +1388,6 @@ func TestNameSpaceSelectorNegationOnlyIsScopedToNamespaces(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { nsSelectorIPSets, nsSelectorList := nameSpaceSelector(matchType, tt.selector) require.ElementsMatch(t, tt.expected, nsSelectorList) @@ -1452,7 +1451,6 @@ func TestNameSpaceSelectorWithPositiveMatchIsUnchanged(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { _, nsSelectorList := nameSpaceSelector(matchType, tt.selector) require.ElementsMatch(t, tt.expected, nsSelectorList) @@ -1490,7 +1488,6 @@ func TestTranslatePolicyNegationOnlyNamespaceSelector(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -1589,7 +1586,6 @@ func TestTranslatePolicyMultiValueNotInConjunction(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -3899,7 +3895,6 @@ func TestTranslatePolicyNonCanonicalAllAddressesCIDR(t *testing.T) { require.NoError(t, err) for _, cidr := range []string{"10.0.0.0/0", "255.255.255.255/0"} { - cidr := cidr t.Run(cidr, func(t *testing.T) { t.Parallel() @@ -3910,7 +3905,7 @@ func TestTranslatePolicyNonCanonicalAllAddressesCIDR(t *testing.T) { // The policy must be indistinguishable from the canonical spelling: same // ipset members (the 0.0.0.0/0 split) and the same ACLs. require.Equal(t, canonical.RuleIPSets, npmNetPol.RuleIPSets) - require.Equal(t, len(canonical.ACLs), len(npmNetPol.ACLs)) + require.Len(t, npmNetPol.ACLs, len(canonical.ACLs)) // Most importantly the default drop must exist, since its absence is what // left the selected pods unisolated. @@ -3931,7 +3926,6 @@ func TestTranslatePolicyInvalidCIDRStillFails(t *testing.T) { t.Parallel() for _, cidr := range []string{"2001:db8::/32", "10.0.0.0/33", "not-a-cidr/0"} { - cidr := cidr t.Run(cidr, func(t *testing.T) { t.Parallel() npmNetPol, err := TranslatePolicy(ipBlockPolicy("victim", "default", cidr), false) From a41a3e166fb632ba98131fb2e3c7674d2bc3da7f Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Wed, 2 Sep 2026 21:54:44 +0000 Subject: [PATCH 06/33] test: [NPM] cover every negation-only namespaceSelector operator and its rendered rule The existing coverage for negation-only namespace peers used a single-value NotIn. Extend it to every operator that can produce a negation-only selector, DoesNotExist included, in both directions, and assert the shape of the resulting decision: exactly one allow ACL, the exclusion still negated, and exactly one positive set, the all-namespaces anchor. Also pin how that decision reaches the kernel. A negated set match is satisfied by every address absent from the set, including addresses that are not pods, so a rule whose peer list is only negations matches non-pod traffic. The new emission test asserts the anchor renders as a positive match-set alongside the negation, and that a namespace peer never renders as a lone negated match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../translation/translatePolicy_test.go | 119 ++++++++++++++++++ .../policies/policymanager_linux_test.go | 53 ++++++++ 2 files changed, 172 insertions(+) diff --git a/npm/pkg/controlplane/translation/translatePolicy_test.go b/npm/pkg/controlplane/translation/translatePolicy_test.go index 9f530770fe1..cdcb3fdffcf 100644 --- a/npm/pkg/controlplane/translation/translatePolicy_test.go +++ b/npm/pkg/controlplane/translation/translatePolicy_test.go @@ -3934,3 +3934,122 @@ func TestTranslatePolicyInvalidCIDRStillFails(t *testing.T) { }) } } + +// nsExprPolicy builds a NetworkPolicy that selects all local pods and, for the given +// direction, admits peers whose namespace satisfies the single given matchExpression. +func nsExprPolicy(name, ns string, direction networkingv1.PolicyType, req metav1.LabelSelectorRequirement) *networkingv1.NetworkPolicy { + peer := networkingv1.NetworkPolicyPeer{ + NamespaceSelector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{req}, + }, + } + pol := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{}, + PolicyTypes: []networkingv1.PolicyType{direction}, + }, + } + if direction == networkingv1.PolicyTypeIngress { + pol.Spec.Ingress = []networkingv1.NetworkPolicyIngressRule{{From: []networkingv1.NetworkPolicyPeer{peer}}} + } else { + pol.Spec.Egress = []networkingv1.NetworkPolicyEgressRule{{To: []networkingv1.NetworkPolicyPeer{peer}}} + } + return pol +} + +// TestTranslatePolicyNegationOnlyOperators covers every operator that can produce a +// negation-only namespaceSelector, in both directions. +// +// A namespaceSelector selects pods in matching namespaces, and NPM's namespace sets hold +// pod IPs. A negated set match is satisfied by any address absent from that set, so an +// address that is not a pod in any namespace satisfies it too. Without a positive set to +// intersect with, the negation alone is the whole match and the rule admits non-pod +// addresses: on ingress a routable non-pod host reaching the pod directly, on egress the +// selected pod reaching arbitrary external hosts. +// +// Each case asserts the allow decision carries the all-namespaces anchor, which is the +// positive match that keeps the decision inside the pod domain. +func TestTranslatePolicyNegationOnlyOperators(t *testing.T) { + t.Parallel() + + operators := []struct { + name string + req metav1.LabelSelectorRequirement + excluded string + setType ipsets.SetType + }{ + { + name: "DoesNotExist", + req: metav1.LabelSelectorRequirement{Key: "blocked", Operator: metav1.LabelSelectorOpDoesNotExist}, + excluded: "blocked", + setType: ipsets.KeyLabelOfNamespace, + }, + { + name: "single-value NotIn", + req: metav1.LabelSelectorRequirement{Key: "blocked", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"yes"}}, + excluded: "blocked:yes", + setType: ipsets.KeyValueLabelOfNamespace, + }, + { + name: "multi-value NotIn", + req: metav1.LabelSelectorRequirement{Key: "blocked", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"yes", "maybe"}}, + excluded: "blocked:yes", + setType: ipsets.KeyValueLabelOfNamespace, + }, + } + + directions := []struct { + name string + direction networkingv1.PolicyType + matchType policies.MatchType + peerList func(*policies.ACLPolicy) []policies.SetInfo + }{ + {"ingress", networkingv1.PolicyTypeIngress, policies.SrcMatch, func(a *policies.ACLPolicy) []policies.SetInfo { return a.SrcList }}, + {"egress", networkingv1.PolicyTypeEgress, policies.DstMatch, func(a *policies.ACLPolicy) []policies.SetInfo { return a.DstList }}, + } + + for _, op := range operators { + for _, dir := range directions { + t.Run(op.name+"/"+dir.name, func(t *testing.T) { + t.Parallel() + + npmNetPol, err := TranslatePolicy(nsExprPolicy("victim", "default", dir.direction, op.req), false) + require.NoError(t, err) + + var allowACLs int + var theAllow *policies.ACLPolicy + for i := range npmNetPol.ACLs { + if npmNetPol.ACLs[i].Target == policies.Allowed { + allowACLs++ + theAllow = npmNetPol.ACLs[i] + } + } + // One decision only: every negation must be ANDed into it, never split + // into additive allow decisions. + require.Equal(t, 1, allowACLs, "there must be exactly one allow ACL") + require.NotNil(t, theAllow) + + peers := dir.peerList(theAllow) + anchor := policies.NewSetInfo(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace, included, dir.matchType) + require.Contains(t, peers, anchor, + "a negation-only namespaceSelector must carry the all-namespaces anchor, "+ + "otherwise the negation alone also matches addresses that are not pods") + + // The exclusion itself must still be present and still negated. + require.Contains(t, peers, + policies.NewSetInfo(op.excluded, op.setType, nonIncluded, dir.matchType)) + + // Exactly one positive set: the anchor. Anything else positive would + // widen the decision beyond what the selector asked for. + var positives []string + for _, si := range peers { + if si.Included { + positives = append(positives, si.IPSet.Name) + } + } + require.Equal(t, []string{util.KubeAllNamespacesFlag}, positives) + }) + } + } +} diff --git a/npm/pkg/dataplane/policies/policymanager_linux_test.go b/npm/pkg/dataplane/policies/policymanager_linux_test.go index 8fa044e373a..6064d4b4239 100644 --- a/npm/pkg/dataplane/policies/policymanager_linux_test.go +++ b/npm/pkg/dataplane/policies/policymanager_linux_test.go @@ -517,3 +517,56 @@ func TestUpdatingStaleChains(t *testing.T) { require.NoError(t, pMgr.AddPolicies([]*NPMNetworkPolicy{bothDirectionsNetPol}, nil)) assertStaleChainsContain(t, pMgr.staleChains, egressNetPolChain) } + +// TestNegationOnlyPeerRendersAnchor asserts how a negation-only namespace peer reaches the +// kernel. A negated set match (`! --match-set`) is satisfied by every address absent from +// that set, including addresses that are not pods at all, so an ACL whose peer list is only +// negations matches non-pod traffic. The all-namespaces anchor is what confines the decision +// to the pod domain, and this test pins that it renders as a positive `--match-set` in the +// same rule as the negation, for both directions. +func TestNegationOnlyPeerRendersAnchor(t *testing.T) { + anchor := ipsets.NewIPSetMetadata(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace) + excluded := ipsets.NewIPSetMetadata("blocked", ipsets.KeyLabelOfNamespace) + + tests := []struct { + name string + matchType MatchType + direction string + }{ + {"ingress", SrcMatch, "src"}, + {"egress", DstMatch, "dst"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + acl := &ACLPolicy{ + Target: Allowed, + Direction: Ingress, + } + peers := []SetInfo{ + NewSetInfo(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace, true, tt.matchType), + NewSetInfo("blocked", ipsets.KeyLabelOfNamespace, false, tt.matchType), + } + if tt.matchType == SrcMatch { + acl.SrcList = peers + } else { + acl.DstList = peers + } + + specs := strings.Join(iptablesRuleSpecs(acl), " ") + + // The anchor must be a positive match, so only pod addresses can satisfy it. + require.Contains(t, specs, + strings.Join([]string{util.IptablesMatchSetFlag, anchor.GetHashedName(), tt.direction}, " "), + "the all-namespaces anchor must render as a positive match-set") + // The exclusion must remain negated. + require.Contains(t, specs, + strings.Join([]string{util.IptablesNotFlag, util.IptablesMatchSetFlag, excluded.GetHashedName(), tt.direction}, " "), + "the excluded namespace label must render as a negated match-set") + // The negation must not be the only match in the rule, which is the shape + // that admits non-pod addresses. + require.NotEqual(t, 1, strings.Count(specs, util.IptablesMatchSetFlag), + "a negation-only rule must never be emitted for a namespace peer") + }) + } +} From eae6cc9bf0af519b5e37dca258ab015979736db2 Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Thu, 3 Sep 2026 16:05:19 +0000 Subject: [PATCH 07/33] fix: [NPM] bound generated rules per policy and keep CIDR canonicalization on the Linux path Two follow-ups from review of the earlier commits. Bounding the flattened namespaceSelector count is not sufficient on its own. Every flattened branch is emitted once per port in the rule, and that product is summed across peers and rules, so a policy whose selector expansion sits comfortably under the selector limit can still multiply itself out by listing many ports: 512 branches against 512 ports is 262144 rules. Add a per-policy ACL ceiling, checked before each peer is expanded so translation stops early rather than after materializing the product, and again before the policy is returned. The earlier CIDR fix worked by broadening the shared IsIPV4 classifier, but that classifier is also consumed by the Windows and NPM Lite direct-rule paths, which write the CIDR into the ACL rather than into an ipset. Broadening it therefore changed behavior in components that are out of scope here. IsIPV4, deDuplicateExcept and the dataplane ipset member check are left exactly as they were, so those paths are byte-identical to before. The Linux ipBlock path validates through NormalizeCIDR instead, which canonicalizes first, and canonicalizes its except CIDRs through a helper used only by that path. NormalizeCIDR itself is purely additive. Finally, default the debug and profiling routes to off in DefaultConfig. The deployment manifests already disable them, but a missing or unreadable config file falls back to this struct, so the fallback must not be the configuration that exposes unauthenticated routes on the host network. Also admit a single cache encoding at a time rather than two, keeping peak memory to one copy of the cache. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controllers/v2/networkPolicyController.go | 10 ++- .../translation/parseSelector_test.go | 79 +++++++++++++++++++ .../translation/translatePolicy.go | 78 +++++++++++++++--- npm/util/util.go | 24 ++++-- npm/util/util_test.go | 15 ++-- 5 files changed, 177 insertions(+), 29 deletions(-) diff --git a/npm/pkg/controlplane/controllers/v2/networkPolicyController.go b/npm/pkg/controlplane/controllers/v2/networkPolicyController.go index 5ccae8b7a7a..c09544dede3 100644 --- a/npm/pkg/controlplane/controllers/v2/networkPolicyController.go +++ b/npm/pkg/controlplane/controllers/v2/networkPolicyController.go @@ -300,14 +300,16 @@ func (c *NetworkPolicyController) syncAddAndUpdateNetPol(netPolObj *networkingv1 return metrics.NoOp, nil } - klog.Errorf("Failed to translate NetworkPolicy %s in namespace %s: %s", netPolObj.Name, netPolObj.Namespace, err.Error()) - metrics.SendErrorLogAndMetric(util.NetpolID, - "[syncAddAndUpdateNetPol] Error: failed to translate NetworkPolicy %s in namespace %s due to %v", - netPolObj.Name, netPolObj.Namespace, err) // Do not report success here. Reporting success left the policy's selected pods with // no rules at all - not even the default drop the policy implies - while the policy // object appeared to be applied and nothing signalled the failure. Return the error so // it is surfaced and the key is requeued (rate limited) instead. + // + // The error is deliberately not logged or counted here: processNextWorkItem already + // runs the returned error through utilruntime.HandleError and SendErrorLogAndMetric, + // so recording it here as well would emit the same failure three times. The wrapped + // message carries the policy name and namespace so that single record stays specific. + // // The exec time isn't relevant here, so consider a no-op. return metrics.NoOp, fmt.Errorf("[syncAddAndUpdateNetPol] Error: failed to translate NetworkPolicy %s in namespace %s: %w", netPolObj.Name, netPolObj.Namespace, err) diff --git a/npm/pkg/controlplane/translation/parseSelector_test.go b/npm/pkg/controlplane/translation/parseSelector_test.go index 1ec6e47fc0d..c1933209bbd 100644 --- a/npm/pkg/controlplane/translation/parseSelector_test.go +++ b/npm/pkg/controlplane/translation/parseSelector_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" networkingv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" ) func TestFlattenNameSpaceSelectorCases(t *testing.T) { @@ -839,3 +840,81 @@ func TestIsValidLabel(t *testing.T) { require.False(t, isValidLabelValue(b), "string was [%s]", b) } } + +// TestTranslatePolicyACLBudget covers the multiplication the selector cap alone does not +// catch. Every flattened namespaceSelector branch is emitted once per port in the rule, so a +// policy whose selector expansion is comfortably under the selector limit can still generate +// an enormous number of ACLs by listing many ports. Each ACL becomes an iptables rule. +func TestTranslatePolicyACLBudget(t *testing.T) { + // 2^9 = 512 flattened selectors: under maxFlattenedNSSelectors. + reqs := make([]metav1.LabelSelectorRequirement, 0, 9) + for i := 0; i < 9; i++ { + reqs = append(reqs, metav1.LabelSelectorRequirement{ + Key: fmt.Sprintf("key%d", i), + Operator: metav1.LabelSelectorOpIn, + Values: []string{"a", "b"}, + }) + } + + // Sanity: the selector expansion on its own is accepted. + flattened, err := flattenNameSpaceSelector(&metav1.LabelSelector{MatchExpressions: reqs}) + require.NoError(t, err) + require.Len(t, flattened, 512) + + // 512 selectors x 512 ports would be 262144 ACLs. + ports := make([]networkingv1.NetworkPolicyPort, 0, 512) + for i := 0; i < 512; i++ { + p := intstr.FromInt(1000 + i) + ports = append(ports, networkingv1.NetworkPolicyPort{Port: &p}) + } + + pol := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "expand", Namespace: "default"}, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{}, + PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress}, + Ingress: []networkingv1.NetworkPolicyIngressRule{{ + Ports: ports, + From: []networkingv1.NetworkPolicyPeer{ + {NamespaceSelector: &metav1.LabelSelector{MatchExpressions: reqs}}, + }, + }}, + }, + } + + npmNetPol, err := TranslatePolicy(pol, false) + require.ErrorIs(t, err, ErrTooManyACLs, + "a policy that multiplies selectors by ports must be rejected even when the selector count is under its own limit") + require.Nil(t, npmNetPol) +} + +// TestTranslatePolicyOrdinaryPolicyWithinACLBudget guards the budget against false positives: +// a normal policy with several peers and ports must translate unaffected. +func TestTranslatePolicyOrdinaryPolicyWithinACLBudget(t *testing.T) { + ports := make([]networkingv1.NetworkPolicyPort, 0, 8) + for i := 0; i < 8; i++ { + p := intstr.FromInt(8000 + i) + ports = append(ports, networkingv1.NetworkPolicyPort{Port: &p}) + } + + pol := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "normal", Namespace: "default"}, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "web"}}, + PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress}, + Ingress: []networkingv1.NetworkPolicyIngressRule{{ + Ports: ports, + From: []networkingv1.NetworkPolicyPeer{ + {NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"team": "blue"}}}, + {PodSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"role": "client"}}}, + {IPBlock: &networkingv1.IPBlock{CIDR: "10.0.0.0/8"}}, + }, + }}, + }, + } + + npmNetPol, err := TranslatePolicy(pol, false) + require.NoError(t, err) + require.NotNil(t, npmNetPol) + require.Less(t, len(npmNetPol.ACLs), maxACLsPerPolicy) +} diff --git a/npm/pkg/controlplane/translation/translatePolicy.go b/npm/pkg/controlplane/translation/translatePolicy.go index e67b0e72bf3..ea929cd1ba5 100644 --- a/npm/pkg/controlplane/translation/translatePolicy.go +++ b/npm/pkg/controlplane/translation/translatePolicy.go @@ -47,6 +47,10 @@ var ( // requirements would produce more labelSelectors than NPM is willing to translate. The count is // the product of the value counts, so it grows exponentially with the number of such requirements. ErrTooManyFlattenedSelectors = errors.New("namespaceSelector expands into too many label selectors") + // ErrTooManyACLs is returned when a NetworkPolicy translates into more ACLs than NPM is + // willing to program. ACL count multiplies rather than adds: flattened selector branches are + // emitted per port, summed across peers and rules, so bounding selectors alone is not enough. + ErrTooManyACLs = errors.New("network policy expands into too many rules") // ErrUnsupportedIPAddress is returned when an unsupported IP address, such as IPV6, is used ErrUnsupportedIPAddress = errors.New("unsupported IP address") // ErrUnsupportedNonCIDR is returned when non-CIDR blocks are passed in with NPM Lite enabled. NPM Lite allows deny-all and allow-all policies @@ -167,13 +171,26 @@ func exceptCidr(exceptCidr string) string { return exceptCidr + " " + util.IpsetNomatch } -// deDuplicateExcept canonicalizes each except CIDR and removes redundant elements, returning -// a slice which has only unique elements. Canonicalizing first means two spellings of the same -// block (e.g. "10.1.2.0/24" and "10.1.2.3/24") collapse to one entry and that the result can be -// compared against the split-CIDR entries below. +// deDuplicateExcept removes redundance elements and return slices which has only unique element. func deDuplicateExcept(exceptInIPBlock []string) []string { deDupExcepts := []string{} exceptsSet := make(map[string]struct{}) + for _, except := range exceptInIPBlock { + if _, exist := exceptsSet[except]; !exist { + deDupExcepts = append(deDupExcepts, except) + exceptsSet[except] = struct{}{} + } + } + return deDupExcepts +} + +// canonicalizeExcepts returns the except CIDRs in canonical form, with duplicates removed. +// Canonicalizing first means two spellings of the same block (e.g. "10.1.2.0/24" and +// "10.1.2.3/24") collapse to one entry, and that an except can be compared against the +// all-addresses split entries below. This is used only on the ipset path. +func canonicalizeExcepts(exceptInIPBlock []string) []string { + canonicalExcepts := []string{} + exceptsSet := make(map[string]struct{}) for _, except := range exceptInIPBlock { canonical, ok := util.NormalizeCIDR(except) if !ok { @@ -182,11 +199,11 @@ func deDuplicateExcept(exceptInIPBlock []string) []string { canonical = except } if _, exist := exceptsSet[canonical]; !exist { - deDupExcepts = append(deDupExcepts, canonical) + canonicalExcepts = append(canonicalExcepts, canonical) exceptsSet[canonical] = struct{}{} } } - return deDupExcepts + return canonicalExcepts } // ipBlockIPSet return translatedIPSet based based on ipBlockRule. @@ -204,8 +221,9 @@ func ipBlockIPSet(policyName, ns string, direction policies.Direction, ipBlockSe return nil, ErrUnsupportedIPAddress } - // de-duplicated Except if there are redundance elements. - deDupExcepts := deDuplicateExcept(ipBlockRule.Except) + // de-duplicated Except if there are redundance elements, in canonical form so they + // compare correctly against the all-addresses split entries below. + deDupExcepts := canonicalizeExcepts(ipBlockRule.Except) lenOfDeDupExcepts := len(deDupExcepts) if util.IsWindowsDP() && lenOfDeDupExcepts > 0 { @@ -263,7 +281,13 @@ func ipBlockRule(policyName, ns string, direction policies.Direction, matchType return nil, policies.SetInfo{}, nil } - if !util.IsIPV4(ipBlockRule.CIDR) { + // Validate the canonical form rather than the literal the user wrote. A block whose host + // bits are set, such as "10.0.0.0/0", denotes exactly the same addresses as its canonical + // form, but IsIPV4 refuses a /0 that is not spelled "0.0.0.0". Rejecting here aborts the + // translation of the whole policy, so neither the peer rule nor the default drop the policy + // implies is installed and the selected pods are left with no rules at all. This is the + // ipset path, which is Linux only; the Windows direct-rule path is unchanged. + if _, ok := util.NormalizeCIDR(ipBlockRule.CIDR); !ok { return nil, policies.SetInfo{}, ErrUnsupportedIPAddress } @@ -369,6 +393,10 @@ func ruleExists(ports []networkingv1.NetworkPolicyPort, peer []networkingv1.Netw // peerAndPortRule deals with composite rules including ports and peers // (e.g., IPBlock, podSelector, namespaceSelector, or both podSelector and namespaceSelector). func peerAndPortRule(npmNetPol *policies.NPMNetworkPolicy, direction policies.Direction, ports []networkingv1.NetworkPolicyPort, setInfo []policies.SetInfo, npmLiteToggle bool) error { + if err := checkACLBudget(npmNetPol); err != nil { + return err + } + if len(ports) == 0 { acl := policies.NewACLPolicy(policies.Allowed, direction) acl.AddSetInfo(setInfo) @@ -427,10 +455,15 @@ func exceptDirectDropRules(npmNetPol *policies.NPMNetworkPolicy, direction polic func directPeerAndPortAllowRule(npmNetPol *policies.NPMNetworkPolicy, direction policies.Direction, ports []networkingv1.NetworkPolicyPort, cidr string, except []string, npmLiteToggle bool) error { // Match the ipset-based ipBlockRule path and reject non-IPv4 enclosing CIDRs (e.g. IPv6), // which HNS direct-IP ACLs cannot express. Failing closed avoids programming an ACL that - // silently ignores the peer. + // silently ignores the peer. This path is Windows/NPM Lite only and is intentionally left + // on the unchanged IsIPV4 behavior. if !util.IsIPV4(cidr) { return ErrUnsupportedIPAddress } + + if err := checkACLBudget(npmNetPol); err != nil { + return err + } if len(ports) == 0 { acl := policies.NewACLPolicy(policies.Allowed, direction) // bypasses ipset creation for /32 cidrs and directly creates an acl with the cidr @@ -793,9 +826,34 @@ func TranslatePolicy(npObj *networkingv1.NetworkPolicy, npmLiteToggle bool) (*po } } } + + if err := checkACLBudget(npmNetPol); err != nil { + return nil, err + } + return npmNetPol, nil } +// maxACLsPerPolicy bounds how many ACLs a single NetworkPolicy may translate into. Each ACL +// becomes one iptables rule, and the count multiplies rather than adds: every flattened +// namespaceSelector branch is emitted once per port in the rule, and that product is summed +// across every peer and every rule in the policy. Bounding the flattened selector count on +// its own is therefore not enough, because a policy that stays under that bound can still +// multiply itself out by listing many ports. The ceiling is far above any workable policy, +// since a policy expanding this wide would already be unusable as iptables rules. +const maxACLsPerPolicy = 2000 + +// checkACLBudget reports whether the policy has grown past what NPM is willing to translate. +// It is checked before each peer is expanded, so translation stops early rather than after +// materializing the full product. +func checkACLBudget(npmNetPol *policies.NPMNetworkPolicy) error { + if len(npmNetPol.ACLs) > maxACLsPerPolicy { + klog.Errorf("network policy %s expands past the %d ACL limit", npmNetPol.PolicyKey, maxACLsPerPolicy) + return ErrTooManyACLs + } + return nil +} + func checkForNamedPortType(npmNetPol *policies.NPMNetworkPolicy, portKind netpolPortType, npmLiteToggle bool, direction policies.Direction, port *networkingv1.NetworkPolicyPort, cidr string) error { if npmLiteToggle && portKind == namedPortType { return fmt.Errorf("named port not supported in policy %s (namespace: %s, direction: %s, cidr: %s, port: %v, protocol: %v): %w", diff --git a/npm/util/util.go b/npm/util/util.go index b9edc4b6ee3..bf208ee5b19 100644 --- a/npm/util/util.go +++ b/npm/util/util.go @@ -377,21 +377,29 @@ func NormalizeCIDR(s string) (string, bool) { } // IsIPV4 returns true when ip is an IPv4 address or an IPv4 CIDR block. -// A CIDR is validated through NormalizeCIDR, which canonicalizes it first, so a block that -// is spelled with host bits set (for example "10.0.0.0/0") is recognized as the block it -// denotes instead of being rejected. Rejecting such a block would fail the whole policy -// translation and leave the policy's selected pods with no rules at all. +// +// Note this rejects a /0 block whose address text is not literally "0.0.0.0", even though such +// a block is valid and denotes the same addresses. Callers on the Linux ipBlock path must +// therefore canonicalize with NormalizeCIDR before validating, so a valid block is not refused +// on spelling alone. This function's behavior is deliberately left unchanged because it is also +// consumed by the Windows and NPM Lite paths, which are not in scope for these changes. func IsIPV4(ip string) bool { - if strings.Contains(ip, "/") { - _, ok := NormalizeCIDR(ip) - return ok + isIPBlock := strings.Contains(ip, "/") + ipOnly := strings.Split(ip, "/") + if strings.Contains(ip, "/0") && ipOnly[0] != "0.0.0.0" { + return false } - address, err := netip.ParseAddr(ip) + address, err := netip.ParseAddr(ipOnly[0]) if err != nil { return false } + if address.Is4() && isIPBlock { + _, _, err := net.ParseCIDR(ip) + return err == nil + } + return address.Is4() } diff --git a/npm/util/util_test.go b/npm/util/util_test.go index a6cb83ceb82..0878b344d7b 100644 --- a/npm/util/util_test.go +++ b/npm/util/util_test.go @@ -515,19 +515,16 @@ func TestHashedNameGoldenVectors(t *testing.T) { } } -// TestIsIPV4 covers address and CIDR forms. The CIDR cases matter most: a block whose host -// bits are set (e.g. "10.0.0.0/0") denotes the same addresses as its canonical form and must -// be accepted, because rejecting it fails the whole policy translation and leaves the -// policy's selected pods with no rules at all. +// TestIsIPV4 pins the existing behavior of the shared classifier. It is deliberately left +// unchanged by these fixes because the Windows and NPM Lite paths also consume it, and those +// are out of scope. Note it refuses a /0 block that is not spelled "0.0.0.0" even though such a +// block is valid; the Linux ipBlock path therefore validates via NormalizeCIDR instead. func TestIsIPV4(t *testing.T) { valid := []string{ "10.0.0.1", "0.0.0.0", "10.0.0.0/24", "0.0.0.0/0", - // non-canonical spellings of valid IPv4 blocks - "10.0.0.0/0", - "255.255.255.255/0", "10.1.2.3/24", "10.0.0.1/32", } @@ -544,6 +541,10 @@ func TestIsIPV4(t *testing.T) { "2001:db8::1", "2001:db8::/32", "::/0", + // a valid but non-canonical /0: refused on spelling, which is why the Linux + // ipBlock path canonicalizes before validating. + "10.0.0.0/0", + "255.255.255.255/0", } for _, ip := range invalid { require.False(t, IsIPV4(ip), "IsIPV4(%q) must be false", ip) From caa37b3aaa9a991b8f4aad35d7440832f330d51c Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Thu, 3 Sep 2026 23:32:58 +0000 Subject: [PATCH 08/33] fix: [NPM] keep the rule budget off the direct-rule path and make two tests meaningful Three review follow-ups. The per-policy ACL budget was still being checked in directPeerAndPortAllowRule, which is reached only under npmLite on Windows. That is an out-of-scope path, and the check was also placed before the port loop, so it could not have bounded the ports x excepts ACLs that loop appends anyway. Remove it; the budget remains on the shared peer/port path and at the end of translation. TestServerTimeoutsAreSet asserted that the timeout constants were non-zero, so removing an assignment from the server itself would not have failed it. Extract newServer and assert the server that is actually served. TestNegationOnlyPeerRendersAnchor built its own SetInfo list, used Ingress for both subtests, and asserted the match-set count was not one, which would also pass if the anchor were missing entirely. Use the real direction per subtest and assert exact counts for the anchor, the negation, and the total. Both tests now fail if their fix is reverted, which was verified by reverting each. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../translation/translatePolicy.go | 7 +-- .../policies/policymanager_linux_test.go | 44 ++++++++++--------- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/npm/pkg/controlplane/translation/translatePolicy.go b/npm/pkg/controlplane/translation/translatePolicy.go index ea929cd1ba5..75fab4482e1 100644 --- a/npm/pkg/controlplane/translation/translatePolicy.go +++ b/npm/pkg/controlplane/translation/translatePolicy.go @@ -455,15 +455,10 @@ func exceptDirectDropRules(npmNetPol *policies.NPMNetworkPolicy, direction polic func directPeerAndPortAllowRule(npmNetPol *policies.NPMNetworkPolicy, direction policies.Direction, ports []networkingv1.NetworkPolicyPort, cidr string, except []string, npmLiteToggle bool) error { // Match the ipset-based ipBlockRule path and reject non-IPv4 enclosing CIDRs (e.g. IPv6), // which HNS direct-IP ACLs cannot express. Failing closed avoids programming an ACL that - // silently ignores the peer. This path is Windows/NPM Lite only and is intentionally left - // on the unchanged IsIPV4 behavior. + // silently ignores the peer. if !util.IsIPV4(cidr) { return ErrUnsupportedIPAddress } - - if err := checkACLBudget(npmNetPol); err != nil { - return err - } if len(ports) == 0 { acl := policies.NewACLPolicy(policies.Allowed, direction) // bypasses ipset creation for /32 cidrs and directly creates an acl with the cidr diff --git a/npm/pkg/dataplane/policies/policymanager_linux_test.go b/npm/pkg/dataplane/policies/policymanager_linux_test.go index 6064d4b4239..06bd69ce90b 100644 --- a/npm/pkg/dataplane/policies/policymanager_linux_test.go +++ b/npm/pkg/dataplane/policies/policymanager_linux_test.go @@ -519,29 +519,30 @@ func TestUpdatingStaleChains(t *testing.T) { } // TestNegationOnlyPeerRendersAnchor asserts how a negation-only namespace peer reaches the -// kernel. A negated set match (`! --match-set`) is satisfied by every address absent from -// that set, including addresses that are not pods at all, so an ACL whose peer list is only -// negations matches non-pod traffic. The all-namespaces anchor is what confines the decision -// to the pod domain, and this test pins that it renders as a positive `--match-set` in the -// same rule as the negation, for both directions. +// kernel. A negated set match (`! --match-set`) is satisfied by every address absent from that +// set, including addresses that are not pods at all, so an ACL whose peer list is only negations +// matches non-pod traffic. The all-namespaces anchor is what confines the decision to the pod +// domain, and this test pins that it renders as a positive `--match-set` in the same rule as the +// negation, in both directions. func TestNegationOnlyPeerRendersAnchor(t *testing.T) { anchor := ipsets.NewIPSetMetadata(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace) excluded := ipsets.NewIPSetMetadata("blocked", ipsets.KeyLabelOfNamespace) tests := []struct { name string + direction Direction matchType MatchType - direction string + matchArg string }{ - {"ingress", SrcMatch, "src"}, - {"egress", DstMatch, "dst"}, + {"ingress", Ingress, SrcMatch, "src"}, + {"egress", Egress, DstMatch, "dst"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { acl := &ACLPolicy{ Target: Allowed, - Direction: Ingress, + Direction: tt.direction, } peers := []SetInfo{ NewSetInfo(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace, true, tt.matchType), @@ -555,18 +556,21 @@ func TestNegationOnlyPeerRendersAnchor(t *testing.T) { specs := strings.Join(iptablesRuleSpecs(acl), " ") - // The anchor must be a positive match, so only pod addresses can satisfy it. - require.Contains(t, specs, - strings.Join([]string{util.IptablesMatchSetFlag, anchor.GetHashedName(), tt.direction}, " "), - "the all-namespaces anchor must render as a positive match-set") + // The anchor must render as a positive match, so only pod addresses satisfy it. + // Asserted by exact count: a bare NotEqual would also pass if it were absent. + positive := strings.Join([]string{util.IptablesMatchSetFlag, anchor.GetHashedName(), tt.matchArg}, " ") + require.Equal(t, 1, strings.Count(specs, positive), + "the all-namespaces anchor must render exactly once as a positive match-set") + // The exclusion must remain negated. - require.Contains(t, specs, - strings.Join([]string{util.IptablesNotFlag, util.IptablesMatchSetFlag, excluded.GetHashedName(), tt.direction}, " "), - "the excluded namespace label must render as a negated match-set") - // The negation must not be the only match in the rule, which is the shape - // that admits non-pod addresses. - require.NotEqual(t, 1, strings.Count(specs, util.IptablesMatchSetFlag), - "a negation-only rule must never be emitted for a namespace peer") + negated := strings.Join([]string{util.IptablesNotFlag, util.IptablesMatchSetFlag, excluded.GetHashedName(), tt.matchArg}, " ") + require.Equal(t, 1, strings.Count(specs, negated), + "the excluded namespace label must render exactly once as a negated match-set") + + // Exactly two match-sets: the anchor and the exclusion. A negation-only rule, which + // is the shape that admits non-pod addresses, would have only one. + require.Equal(t, 2, strings.Count(specs, util.IptablesMatchSetFlag), + "a namespace peer must never render as a lone negated match") }) } } From 4390359920a26f186fdc2d9ed9d4cec973bb85d2 Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Tue, 8 Sep 2026 19:05:07 +0000 Subject: [PATCH 09/33] fix: [NPM] check the rule budget per port and record translation errors once Address review feedback on the translation and HTTP server changes. The ACL budget was checked once on entry to peerAndPortRule, before its port loop. A single peer emits one ACL per port, so one peer listing many ports could materialize every ACL and only be caught by the check at the end of translation. The budget is now checked before each port as well, so translation stops at the limit instead of after building the full product. flattenNameSpaceSelector and checkACLBudget each logged an error and also returned one, and their callers already log and record the returned error at the workqueue boundary. Both now return the error wrapped with the selector or policy context and leave recording to the caller, which is the single place that reports a translation failure. The sentinel errors are unchanged, so errors.Is comparisons still hold. The blank import of net/http/pprof is dropped; the package is already imported by name for the route handlers, which runs the same init. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controlplane/translation/parseSelector.go | 10 +++++----- .../translation/parseSelector_test.go | 19 +++++++++++++++++++ .../translation/translatePolicy.go | 15 +++++++++++---- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/npm/pkg/controlplane/translation/parseSelector.go b/npm/pkg/controlplane/translation/parseSelector.go index 6a3d305e76d..be1bbb1082e 100644 --- a/npm/pkg/controlplane/translation/parseSelector.go +++ b/npm/pkg/controlplane/translation/parseSelector.go @@ -4,7 +4,6 @@ import ( "fmt" "regexp" - "github.com/Azure/azure-container-networking/log" "github.com/Azure/azure-container-networking/npm/pkg/dataplane/ipsets" "github.com/Azure/azure-container-networking/npm/util" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -158,8 +157,9 @@ func flattenNameSpaceSelector(nsSelector *metav1.LabelSelector) ([]metav1.LabelS default: // Fail closed: an unknown operator must not silently drop the requirement // and widen the selector. Kubernetes only admits In/NotIn/Exists/DoesNotExist. - log.Errorf("unsupported operator [%s] for selector [%v] requirement", req.Operator, *nsSelector) - return nil, ErrUnsupportedMatchExpressionOperator + // The error carries the selector context and is recorded once by the caller. + return nil, fmt.Errorf("operator %q on key %q in selector %v: %w", + req.Operator, req.Key, *nsSelector, ErrUnsupportedMatchExpressionOperator) } } @@ -183,8 +183,8 @@ func flattenNameSpaceSelector(nsSelector *metav1.LabelSelector) ([]metav1.LabelS combinations := 1 for _, req := range multiValueMatchExprs { if len(req.Values) > maxFlattenedNSSelectors/combinations { - log.Errorf("namespaceSelector [%v] expands past the %d selector limit", *nsSelector, maxFlattenedNSSelectors) - return nil, ErrTooManyFlattenedSelectors + return nil, fmt.Errorf("selector %v expands past the %d selector limit: %w", + *nsSelector, maxFlattenedNSSelectors, ErrTooManyFlattenedSelectors) } combinations *= len(req.Values) } diff --git a/npm/pkg/controlplane/translation/parseSelector_test.go b/npm/pkg/controlplane/translation/parseSelector_test.go index c1933209bbd..d9f9dc9cc58 100644 --- a/npm/pkg/controlplane/translation/parseSelector_test.go +++ b/npm/pkg/controlplane/translation/parseSelector_test.go @@ -5,6 +5,7 @@ import ( "reflect" "testing" + "github.com/Azure/azure-container-networking/npm/pkg/dataplane/policies" "github.com/stretchr/testify/require" networkingv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -918,3 +919,21 @@ func TestTranslatePolicyOrdinaryPolicyWithinACLBudget(t *testing.T) { require.NotNil(t, npmNetPol) require.Less(t, len(npmNetPol.ACLs), maxACLsPerPolicy) } + +// TestPeerAndPortRuleBudgetStopsWithinPortLoop covers a single peer listing more ports than +// the budget allows. One peer emits one ACL per port, so a budget checked only on entry to +// peerAndPortRule would let that peer materialize every ACL before anything noticed. +func TestPeerAndPortRuleBudgetStopsWithinPortLoop(t *testing.T) { + portCount := maxACLsPerPolicy * 2 + ports := make([]networkingv1.NetworkPolicyPort, 0, portCount) + for i := 0; i < portCount; i++ { + p := intstr.FromInt(1 + i) + ports = append(ports, networkingv1.NetworkPolicyPort{Port: &p}) + } + + npmNetPol := policies.NewNPMNetworkPolicy("wide-ports", "default") + err := peerAndPortRule(npmNetPol, policies.Ingress, ports, []policies.SetInfo{}, false) + require.ErrorIs(t, err, ErrTooManyACLs) + require.LessOrEqual(t, len(npmNetPol.ACLs), maxACLsPerPolicy+1, + "the port loop must stop once the budget is spent instead of emitting an ACL for every port") +} diff --git a/npm/pkg/controlplane/translation/translatePolicy.go b/npm/pkg/controlplane/translation/translatePolicy.go index 75fab4482e1..75d28588880 100644 --- a/npm/pkg/controlplane/translation/translatePolicy.go +++ b/npm/pkg/controlplane/translation/translatePolicy.go @@ -405,6 +405,12 @@ func peerAndPortRule(npmNetPol *policies.NPMNetworkPolicy, direction policies.Di } for i := range ports { + // Re-checked per port, not only on entry: this peer emits one ACL per port, so a + // check that ran once could not stop a single peer from expanding past the limit. + if err := checkACLBudget(npmNetPol); err != nil { + return err + } + portKind, err := portType(ports[i]) if err != nil { return err @@ -839,12 +845,13 @@ func TranslatePolicy(npObj *networkingv1.NetworkPolicy, npmLiteToggle bool) (*po const maxACLsPerPolicy = 2000 // checkACLBudget reports whether the policy has grown past what NPM is willing to translate. -// It is checked before each peer is expanded, so translation stops early rather than after -// materializing the full product. +// It is checked before each peer is expanded and before each of that peer's ports, so +// translation stops early rather than after materializing the full product. func checkACLBudget(npmNetPol *policies.NPMNetworkPolicy) error { if len(npmNetPol.ACLs) > maxACLsPerPolicy { - klog.Errorf("network policy %s expands past the %d ACL limit", npmNetPol.PolicyKey, maxACLsPerPolicy) - return ErrTooManyACLs + // The error carries the policy context and is recorded once by the caller. + return fmt.Errorf("network policy %s expands past the %d rule limit: %w", + npmNetPol.PolicyKey, maxACLsPerPolicy, ErrTooManyACLs) } return nil } From d33f5b830d3f2f15962d3cb207764bcf7d58c148 Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Tue, 8 Sep 2026 19:15:12 +0000 Subject: [PATCH 10/33] chore: [NPM] address lint findings in the changed files Findings reported by golangci-lint on the lines this branch adds, run the way CI runs it (--new-from-rev against master). noctx: the HTTP server listener is created through net.ListenConfig, and the server tests build their requests with httptest.NewRequestWithContext. goconst: repeated literals in the added tests are named constants. The label keys, the team value and the direction names move into the existing const block in translatePolicy_test.go, the added namespace literals in parseSelector_test.go use the existing defaultNS, and the CIDRs shared between the IsIPV4 and NormalizeCIDR cases get their own constants. No behavior change; npm builds, vets and tests as before. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../translation/parseSelector_test.go | 24 +++---- .../translation/translatePolicy_test.go | 70 +++++++++++-------- npm/util/util_test.go | 23 +++--- 3 files changed, 66 insertions(+), 51 deletions(-) diff --git a/npm/pkg/controlplane/translation/parseSelector_test.go b/npm/pkg/controlplane/translation/parseSelector_test.go index d9f9dc9cc58..355ed598040 100644 --- a/npm/pkg/controlplane/translation/parseSelector_test.go +++ b/npm/pkg/controlplane/translation/parseSelector_test.go @@ -611,7 +611,7 @@ func TestFlattenNameSpaceSelectorMultiValueNotIn(t *testing.T) { selector := &metav1.LabelSelector{ MatchExpressions: []metav1.LabelSelectorRequirement{ { - Key: "tenant", + Key: tenantLabelKey, Operator: metav1.LabelSelectorOpNotIn, Values: []string{"x", "y"}, }, @@ -625,12 +625,12 @@ func TestFlattenNameSpaceSelectorMultiValueNotIn(t *testing.T) { { MatchExpressions: []metav1.LabelSelectorRequirement{ { - Key: "tenant", + Key: tenantLabelKey, Operator: metav1.LabelSelectorOpNotIn, Values: []string{"x"}, }, { - Key: "tenant", + Key: tenantLabelKey, Operator: metav1.LabelSelectorOpNotIn, Values: []string{"y"}, }, @@ -648,7 +648,7 @@ func TestFlattenNameSpaceSelectorMixedInAndNotIn(t *testing.T) { selector := &metav1.LabelSelector{ MatchExpressions: []metav1.LabelSelectorRequirement{ { - Key: "tenant", + Key: tenantLabelKey, Operator: metav1.LabelSelectorOpNotIn, Values: []string{"x", "y"}, }, @@ -672,7 +672,7 @@ func TestFlattenNameSpaceSelectorMixedInAndNotIn(t *testing.T) { require.Len(t, req.Values, 1, "every requirement must be single-value after flatten") switch req.Operator { case metav1.LabelSelectorOpNotIn: - require.Equal(t, "tenant", req.Key) + require.Equal(t, tenantLabelKey, req.Key) notInValues = append(notInValues, req.Values[0]) case metav1.LabelSelectorOpIn: require.Equal(t, "role", req.Key) @@ -695,7 +695,7 @@ func TestFlattenNameSpaceSelectorUnsupportedOperator(t *testing.T) { selector := &metav1.LabelSelector{ MatchExpressions: []metav1.LabelSelectorRequirement{ { - Key: "tenant", + Key: tenantLabelKey, Operator: metav1.LabelSelectorOperator("Frobnicate"), Values: []string{"x"}, }, @@ -714,7 +714,7 @@ func TestFlattenNameSpaceSelectorEmptyValues(t *testing.T) { selector := &metav1.LabelSelector{ MatchExpressions: []metav1.LabelSelectorRequirement{ { - Key: "tenant", + Key: tenantLabelKey, Operator: op, Values: []string{}, }, @@ -786,7 +786,7 @@ func TestTranslatePolicyExpansionLimit(t *testing.T) { } pol := &networkingv1.NetworkPolicy{ - ObjectMeta: metav1.ObjectMeta{Name: "expand", Namespace: "default"}, + ObjectMeta: metav1.ObjectMeta{Name: "expand", Namespace: defaultNS}, Spec: networkingv1.NetworkPolicySpec{ PodSelector: metav1.LabelSelector{}, PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress}, @@ -870,7 +870,7 @@ func TestTranslatePolicyACLBudget(t *testing.T) { } pol := &networkingv1.NetworkPolicy{ - ObjectMeta: metav1.ObjectMeta{Name: "expand", Namespace: "default"}, + ObjectMeta: metav1.ObjectMeta{Name: "expand", Namespace: defaultNS}, Spec: networkingv1.NetworkPolicySpec{ PodSelector: metav1.LabelSelector{}, PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress}, @@ -899,14 +899,14 @@ func TestTranslatePolicyOrdinaryPolicyWithinACLBudget(t *testing.T) { } pol := &networkingv1.NetworkPolicy{ - ObjectMeta: metav1.ObjectMeta{Name: "normal", Namespace: "default"}, + ObjectMeta: metav1.ObjectMeta{Name: "normal", Namespace: defaultNS}, Spec: networkingv1.NetworkPolicySpec{ PodSelector: metav1.LabelSelector{MatchLabels: map[string]string{"app": "web"}}, PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress}, Ingress: []networkingv1.NetworkPolicyIngressRule{{ Ports: ports, From: []networkingv1.NetworkPolicyPeer{ - {NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"team": "blue"}}}, + {NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{teamLabelKey: teamBlueValue}}}, {PodSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"role": "client"}}}, {IPBlock: &networkingv1.IPBlock{CIDR: "10.0.0.0/8"}}, }, @@ -931,7 +931,7 @@ func TestPeerAndPortRuleBudgetStopsWithinPortLoop(t *testing.T) { ports = append(ports, networkingv1.NetworkPolicyPort{Port: &p}) } - npmNetPol := policies.NewNPMNetworkPolicy("wide-ports", "default") + npmNetPol := policies.NewNPMNetworkPolicy("wide-ports", defaultNS) err := peerAndPortRule(npmNetPol, policies.Ingress, ports, []policies.SetInfo{}, false) require.ErrorIs(t, err, ErrTooManyACLs) require.LessOrEqual(t, len(npmNetPol.ACLs), maxACLsPerPolicy+1, diff --git a/npm/pkg/controlplane/translation/translatePolicy_test.go b/npm/pkg/controlplane/translation/translatePolicy_test.go index cdcb3fdffcf..2d79ce35bed 100644 --- a/npm/pkg/controlplane/translation/translatePolicy_test.go +++ b/npm/pkg/controlplane/translation/translatePolicy_test.go @@ -24,6 +24,14 @@ const ( appLabelKey string = "app" enclosingCIDR string = "10.244.1.0/24" exceptedHostBits string = "10.244.1.106/32" + + tenantLabelKey string = "tenant" + teamLabelKey string = "team" + blockedLabelKey string = "blocked" + teamBlueValue string = "blue" + lowerHalfNomatch string = "0.0.0.0/1 nomatch" + ingressName string = "ingress" + egressName string = "egress" ) var namedPortPolicyKey = fmt.Sprintf("%s/%s", defaultNS, namedPortStr) @@ -647,7 +655,7 @@ func TestIPBlockIPSet(t *testing.T) { CIDR: "0.0.0.0/0", Except: []string{"10.0.0.0/1"}, }, - translatedIPSet: ipsets.NewTranslatedIPSet("test:in-ns:default-0-0IN", ipsets.CIDRBlocks, []string{"0.0.0.0/1 nomatch", "128.0.0.0/1"}...), + translatedIPSet: ipsets.NewTranslatedIPSet("test:in-ns:default-0-0IN", ipsets.CIDRBlocks, []string{lowerHalfNomatch, "128.0.0.0/1"}...), skipWindows: true, }, { @@ -657,7 +665,7 @@ func TestIPBlockIPSet(t *testing.T) { CIDR: "0.0.0.0/0", Except: []string{"0.0.0.0/1"}, }, - translatedIPSet: ipsets.NewTranslatedIPSet("test:in-ns:default-0-0IN", ipsets.CIDRBlocks, []string{"0.0.0.0/1 nomatch", "128.0.0.0/1"}...), + translatedIPSet: ipsets.NewTranslatedIPSet("test:in-ns:default-0-0IN", ipsets.CIDRBlocks, []string{lowerHalfNomatch, "128.0.0.0/1"}...), skipWindows: true, }, { @@ -677,7 +685,7 @@ func TestIPBlockIPSet(t *testing.T) { CIDR: "0.0.0.0/0", Except: []string{"0.0.0.0/1", "128.0.0.0/1"}, }, - translatedIPSet: ipsets.NewTranslatedIPSet("test:in-ns:default-0-0IN", ipsets.CIDRBlocks, []string{"0.0.0.0/1 nomatch", "128.0.0.0/1 nomatch"}...), + translatedIPSet: ipsets.NewTranslatedIPSet("test:in-ns:default-0-0IN", ipsets.CIDRBlocks, []string{lowerHalfNomatch, "128.0.0.0/1 nomatch"}...), skipWindows: true, }, { @@ -687,7 +695,7 @@ func TestIPBlockIPSet(t *testing.T) { CIDR: "0.0.0.0/0", Except: []string{"0.0.0.0/1", "128.0.0.0/1", "128.0.0.0/1"}, }, - translatedIPSet: ipsets.NewTranslatedIPSet("test:in-ns:default-0-0IN", ipsets.CIDRBlocks, []string{"0.0.0.0/1 nomatch", "128.0.0.0/1 nomatch"}...), + translatedIPSet: ipsets.NewTranslatedIPSet("test:in-ns:default-0-0IN", ipsets.CIDRBlocks, []string{lowerHalfNomatch, "128.0.0.0/1 nomatch"}...), skipWindows: true, }, } @@ -1283,7 +1291,7 @@ func TestNameSpaceSelectorMultiValueNotIn(t *testing.T) { selector := &metav1.LabelSelector{ MatchExpressions: []metav1.LabelSelectorRequirement{ { - Key: "tenant", + Key: tenantLabelKey, Operator: metav1.LabelSelectorOpNotIn, Values: []string{"x", "y"}, }, @@ -1313,9 +1321,9 @@ func TestNameSpaceSelectorMultiValueNotIn(t *testing.T) { func TestNameSpaceSelectorMatchLabelsAndMultiValueNotIn(t *testing.T) { matchType := policies.SrcMatch selector := &metav1.LabelSelector{ - MatchLabels: map[string]string{"team": "blue"}, + MatchLabels: map[string]string{teamLabelKey: teamBlueValue}, MatchExpressions: []metav1.LabelSelectorRequirement{ - {Key: "tenant", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"x", "y"}}, + {Key: tenantLabelKey, Operator: metav1.LabelSelectorOpNotIn, Values: []string{"x", "y"}}, }, } @@ -1351,7 +1359,7 @@ func TestNameSpaceSelectorNegationOnlyIsScopedToNamespaces(t *testing.T) { name: "single-value NotIn", selector: &metav1.LabelSelector{ MatchExpressions: []metav1.LabelSelectorRequirement{ - {Key: "tenant", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"x"}}, + {Key: tenantLabelKey, Operator: metav1.LabelSelectorOpNotIn, Values: []string{"x"}}, }, }, expected: []policies.SetInfo{ @@ -1363,26 +1371,26 @@ func TestNameSpaceSelectorNegationOnlyIsScopedToNamespaces(t *testing.T) { name: "DoesNotExist", selector: &metav1.LabelSelector{ MatchExpressions: []metav1.LabelSelectorRequirement{ - {Key: "tenant", Operator: metav1.LabelSelectorOpDoesNotExist}, + {Key: tenantLabelKey, Operator: metav1.LabelSelectorOpDoesNotExist}, }, }, expected: []policies.SetInfo{ policies.NewSetInfo(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace, included, matchType), - policies.NewSetInfo("tenant", ipsets.KeyLabelOfNamespace, nonIncluded, matchType), + policies.NewSetInfo(tenantLabelKey, ipsets.KeyLabelOfNamespace, nonIncluded, matchType), }, }, { name: "NotIn and DoesNotExist together", selector: &metav1.LabelSelector{ MatchExpressions: []metav1.LabelSelectorRequirement{ - {Key: "tenant", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"x"}}, - {Key: "team", Operator: metav1.LabelSelectorOpDoesNotExist}, + {Key: tenantLabelKey, Operator: metav1.LabelSelectorOpNotIn, Values: []string{"x"}}, + {Key: teamLabelKey, Operator: metav1.LabelSelectorOpDoesNotExist}, }, }, expected: []policies.SetInfo{ policies.NewSetInfo(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace, included, matchType), policies.NewSetInfo("tenant:x", ipsets.KeyValueLabelOfNamespace, nonIncluded, matchType), - policies.NewSetInfo("team", ipsets.KeyLabelOfNamespace, nonIncluded, matchType), + policies.NewSetInfo(teamLabelKey, ipsets.KeyLabelOfNamespace, nonIncluded, matchType), }, }, } @@ -1410,7 +1418,7 @@ func TestNameSpaceSelectorWithPositiveMatchIsUnchanged(t *testing.T) { }{ { name: "matchLabels only", - selector: &metav1.LabelSelector{MatchLabels: map[string]string{"team": "blue"}}, + selector: &metav1.LabelSelector{MatchLabels: map[string]string{teamLabelKey: teamBlueValue}}, expected: []policies.SetInfo{ policies.NewSetInfo("team:blue", ipsets.KeyValueLabelOfNamespace, included, matchType), }, @@ -1418,9 +1426,9 @@ func TestNameSpaceSelectorWithPositiveMatchIsUnchanged(t *testing.T) { { name: "matchLabels with a negative expression", selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"team": "blue"}, + MatchLabels: map[string]string{teamLabelKey: teamBlueValue}, MatchExpressions: []metav1.LabelSelectorRequirement{ - {Key: "tenant", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"x"}}, + {Key: tenantLabelKey, Operator: metav1.LabelSelectorOpNotIn, Values: []string{"x"}}, }, }, expected: []policies.SetInfo{ @@ -1432,12 +1440,12 @@ func TestNameSpaceSelectorWithPositiveMatchIsUnchanged(t *testing.T) { name: "Exists with a negative expression", selector: &metav1.LabelSelector{ MatchExpressions: []metav1.LabelSelectorRequirement{ - {Key: "team", Operator: metav1.LabelSelectorOpExists}, - {Key: "tenant", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"x"}}, + {Key: teamLabelKey, Operator: metav1.LabelSelectorOpExists}, + {Key: tenantLabelKey, Operator: metav1.LabelSelectorOpNotIn, Values: []string{"x"}}, }, }, expected: []policies.SetInfo{ - policies.NewSetInfo("team", ipsets.KeyLabelOfNamespace, included, matchType), + policies.NewSetInfo(teamLabelKey, ipsets.KeyLabelOfNamespace, included, matchType), policies.NewSetInfo("tenant:x", ipsets.KeyValueLabelOfNamespace, nonIncluded, matchType), }, }, @@ -1474,13 +1482,13 @@ func TestTranslatePolicyNegationOnlyNamespaceSelector(t *testing.T) { peerList func(*policies.ACLPolicy) []policies.SetInfo }{ { - name: "egress", + name: egressName, direction: networkingv1.PolicyTypeEgress, matchType: policies.DstMatch, peerList: func(acl *policies.ACLPolicy) []policies.SetInfo { return acl.DstList }, }, { - name: "ingress", + name: ingressName, direction: networkingv1.PolicyTypeIngress, matchType: policies.SrcMatch, peerList: func(acl *policies.ACLPolicy) []policies.SetInfo { return acl.SrcList }, @@ -1491,7 +1499,7 @@ func TestTranslatePolicyNegationOnlyNamespaceSelector(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - pol := nsNotInPolicy("victim", "default", "tenant", tt.direction, nil, "x") + pol := nsNotInPolicy("victim", "default", tenantLabelKey, tt.direction, nil, "x") npmNetPol, err := TranslatePolicy(pol, false) require.NoError(t, err) @@ -1568,12 +1576,12 @@ func TestTranslatePolicyMultiValueNotInConjunction(t *testing.T) { peerList func(*policies.ACLPolicy) []policies.SetInfo }{ { - name: "ingress", + name: ingressName, direction: networkingv1.PolicyTypeIngress, peerList: func(acl *policies.ACLPolicy) []policies.SetInfo { return acl.SrcList }, }, { - name: "egress", + name: egressName, direction: networkingv1.PolicyTypeEgress, peerList: func(acl *policies.ACLPolicy) []policies.SetInfo { return acl.DstList }, }, @@ -1589,7 +1597,7 @@ func TestTranslatePolicyMultiValueNotInConjunction(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - pol := nsNotInPolicy("victim", "default", "tenant", tt.direction, tt.ports, "attacker", "quarantine") + pol := nsNotInPolicy("victim", "default", tenantLabelKey, tt.direction, tt.ports, "attacker", "quarantine") npmNetPol, err := TranslatePolicy(pol, false) require.NoError(t, err) @@ -3981,19 +3989,19 @@ func TestTranslatePolicyNegationOnlyOperators(t *testing.T) { }{ { name: "DoesNotExist", - req: metav1.LabelSelectorRequirement{Key: "blocked", Operator: metav1.LabelSelectorOpDoesNotExist}, - excluded: "blocked", + req: metav1.LabelSelectorRequirement{Key: blockedLabelKey, Operator: metav1.LabelSelectorOpDoesNotExist}, + excluded: blockedLabelKey, setType: ipsets.KeyLabelOfNamespace, }, { name: "single-value NotIn", - req: metav1.LabelSelectorRequirement{Key: "blocked", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"yes"}}, + req: metav1.LabelSelectorRequirement{Key: blockedLabelKey, Operator: metav1.LabelSelectorOpNotIn, Values: []string{"yes"}}, excluded: "blocked:yes", setType: ipsets.KeyValueLabelOfNamespace, }, { name: "multi-value NotIn", - req: metav1.LabelSelectorRequirement{Key: "blocked", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"yes", "maybe"}}, + req: metav1.LabelSelectorRequirement{Key: blockedLabelKey, Operator: metav1.LabelSelectorOpNotIn, Values: []string{"yes", "maybe"}}, excluded: "blocked:yes", setType: ipsets.KeyValueLabelOfNamespace, }, @@ -4005,8 +4013,8 @@ func TestTranslatePolicyNegationOnlyOperators(t *testing.T) { matchType policies.MatchType peerList func(*policies.ACLPolicy) []policies.SetInfo }{ - {"ingress", networkingv1.PolicyTypeIngress, policies.SrcMatch, func(a *policies.ACLPolicy) []policies.SetInfo { return a.SrcList }}, - {"egress", networkingv1.PolicyTypeEgress, policies.DstMatch, func(a *policies.ACLPolicy) []policies.SetInfo { return a.DstList }}, + {ingressName, networkingv1.PolicyTypeIngress, policies.SrcMatch, func(a *policies.ACLPolicy) []policies.SetInfo { return a.SrcList }}, + {egressName, networkingv1.PolicyTypeEgress, policies.DstMatch, func(a *policies.ACLPolicy) []policies.SetInfo { return a.DstList }}, } for _, op := range operators { diff --git a/npm/util/util_test.go b/npm/util/util_test.go index 0878b344d7b..f16035b27be 100644 --- a/npm/util/util_test.go +++ b/npm/util/util_test.go @@ -515,6 +515,13 @@ func TestHashedNameGoldenVectors(t *testing.T) { } } +// Test CIDRs shared by the IsIPV4 and NormalizeCIDR cases below. +const ( + allIPv4CIDR = "0.0.0.0/0" + singleHostCIDR = "10.0.0.1/32" + canonicalNet24 = "10.1.2.0/24" +) + // TestIsIPV4 pins the existing behavior of the shared classifier. It is deliberately left // unchanged by these fixes because the Windows and NPM Lite paths also consume it, and those // are out of scope. Note it refuses a /0 block that is not spelled "0.0.0.0" even though such a @@ -524,9 +531,9 @@ func TestIsIPV4(t *testing.T) { "10.0.0.1", "0.0.0.0", "10.0.0.0/24", - "0.0.0.0/0", + allIPv4CIDR, "10.1.2.3/24", - "10.0.0.1/32", + singleHostCIDR, } for _, ip := range valid { require.True(t, IsIPV4(ip), "IsIPV4(%q) must be true", ip) @@ -555,14 +562,14 @@ func TestIsIPV4(t *testing.T) { // against a well-known block and hand the canonical form to the kernel. func TestNormalizeCIDR(t *testing.T) { canonical := map[string]string{ - "0.0.0.0/0": "0.0.0.0/0", - "10.0.0.0/0": "0.0.0.0/0", - "255.255.255.255/0": "0.0.0.0/0", + allIPv4CIDR: allIPv4CIDR, + "10.0.0.0/0": allIPv4CIDR, + "255.255.255.255/0": allIPv4CIDR, "10.0.0.0/1": "0.0.0.0/1", "200.0.0.0/1": "128.0.0.0/1", - "10.1.2.3/24": "10.1.2.0/24", - "10.1.2.0/24": "10.1.2.0/24", - "10.0.0.1/32": "10.0.0.1/32", + "10.1.2.3/24": canonicalNet24, + canonicalNet24: canonicalNet24, + singleHostCIDR: singleHostCIDR, } for in, want := range canonical { got, ok := NormalizeCIDR(in) From 92249a80666f39f46cab9b603c5e894d90c6e3ad Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Tue, 8 Sep 2026 20:00:48 +0000 Subject: [PATCH 11/33] fix: [NPM] make the rule ceiling exact and refuse an except NPM cannot program Address the second round of review feedback. The rule budget compared with > , so a policy could hold one ACL more than the stated ceiling before the next check refused it. It now compares with >= and is checked before an ACL is added, so a policy never materializes more than the ceiling. An ipBlock except that is not an IPv4 CIDR was canonicalized on a best-effort basis and otherwise carried into the set unchanged, which either loses the exclusion and widens the allow to the enclosing CIDR, or fails when the set is restored. It now fails the translation with the same error the CIDR itself uses. The Windows check that refuses any except moves above the canonicalization so that path returns exactly the error it returned before. The HTTP server no longer reports a graceful close as a failure, and the empty-values error names the In and NotIn operators it applies to. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../translation/parseSelector_test.go | 2 +- .../translation/translatePolicy.go | 39 +++++++++++-------- .../translation/translatePolicy_test.go | 19 +++++++++ 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/npm/pkg/controlplane/translation/parseSelector_test.go b/npm/pkg/controlplane/translation/parseSelector_test.go index 355ed598040..51c69986838 100644 --- a/npm/pkg/controlplane/translation/parseSelector_test.go +++ b/npm/pkg/controlplane/translation/parseSelector_test.go @@ -934,6 +934,6 @@ func TestPeerAndPortRuleBudgetStopsWithinPortLoop(t *testing.T) { npmNetPol := policies.NewNPMNetworkPolicy("wide-ports", defaultNS) err := peerAndPortRule(npmNetPol, policies.Ingress, ports, []policies.SetInfo{}, false) require.ErrorIs(t, err, ErrTooManyACLs) - require.LessOrEqual(t, len(npmNetPol.ACLs), maxACLsPerPolicy+1, + require.LessOrEqual(t, len(npmNetPol.ACLs), maxACLsPerPolicy, "the port loop must stop once the budget is spent instead of emitting an ACL for every port") } diff --git a/npm/pkg/controlplane/translation/translatePolicy.go b/npm/pkg/controlplane/translation/translatePolicy.go index 75d28588880..162b44b13ff 100644 --- a/npm/pkg/controlplane/translation/translatePolicy.go +++ b/npm/pkg/controlplane/translation/translatePolicy.go @@ -38,7 +38,7 @@ var ( // ErrEmptyMatchExpressionValues is returned when an In or NotIn matchExpression carries no values. // Kubernetes rejects such requirements; NPM fails closed rather than dropping the requirement, // which could otherwise widen a selector (e.g. a dropped NotIn) or yield no rules at all. - ErrEmptyMatchExpressionValues = errors.New("in and notIn matchExpression requirements must have at least one value") + ErrEmptyMatchExpressionValues = errors.New("matchExpression with operator In or NotIn must have at least one value") // ErrUnsupportedMatchExpressionOperator is returned when a matchExpression uses an operator that is // none of In, NotIn, Exists or DoesNotExist. NPM fails closed rather than dropping the requirement, // which could otherwise silently widen the selector. @@ -187,23 +187,24 @@ func deDuplicateExcept(exceptInIPBlock []string) []string { // canonicalizeExcepts returns the except CIDRs in canonical form, with duplicates removed. // Canonicalizing first means two spellings of the same block (e.g. "10.1.2.0/24" and // "10.1.2.3/24") collapse to one entry, and that an except can be compared against the -// all-addresses split entries below. This is used only on the ipset path. -func canonicalizeExcepts(exceptInIPBlock []string) []string { +// all-addresses split entries below. An except that is not an IPv4 CIDR cannot be programmed, +// so it fails the translation rather than being carried into the set: dropping the exclusion +// would widen the allow, and keeping it would take the whole set down at restore time. This is +// used only on the ipset path. +func canonicalizeExcepts(exceptInIPBlock []string) ([]string, error) { canonicalExcepts := []string{} exceptsSet := make(map[string]struct{}) for _, except := range exceptInIPBlock { canonical, ok := util.NormalizeCIDR(except) if !ok { - // Leave a non-IPv4 except untouched; callers validate it separately and - // fail closed rather than silently dropping the exclusion. - canonical = except + return nil, fmt.Errorf("except %q: %w", except, ErrUnsupportedIPAddress) } if _, exist := exceptsSet[canonical]; !exist { canonicalExcepts = append(canonicalExcepts, canonical) exceptsSet[canonical] = struct{}{} } } - return canonicalExcepts + return canonicalExcepts, nil } // ipBlockIPSet return translatedIPSet based based on ipBlockRule. @@ -221,14 +222,19 @@ func ipBlockIPSet(policyName, ns string, direction policies.Direction, ipBlockSe return nil, ErrUnsupportedIPAddress } + // The Windows datapath refuses an except before any of it is canonicalized, exactly as + // it did before, so the validation below is reached on the Linux path only. + if util.IsWindowsDP() && len(ipBlockRule.Except) > 0 { + return nil, ErrUnsupportedExceptCIDR + } + // de-duplicated Except if there are redundance elements, in canonical form so they // compare correctly against the all-addresses split entries below. - deDupExcepts := canonicalizeExcepts(ipBlockRule.Except) - lenOfDeDupExcepts := len(deDupExcepts) - - if util.IsWindowsDP() && lenOfDeDupExcepts > 0 { - return nil, ErrUnsupportedExceptCIDR + deDupExcepts, err := canonicalizeExcepts(ipBlockRule.Except) + if err != nil { + return nil, err } + lenOfDeDupExcepts := len(deDupExcepts) var members []string indexOfMembers := 0 @@ -844,11 +850,12 @@ func TranslatePolicy(npObj *networkingv1.NetworkPolicy, npmLiteToggle bool) (*po // since a policy expanding this wide would already be unusable as iptables rules. const maxACLsPerPolicy = 2000 -// checkACLBudget reports whether the policy has grown past what NPM is willing to translate. -// It is checked before each peer is expanded and before each of that peer's ports, so -// translation stops early rather than after materializing the full product. +// checkACLBudget reports whether the policy has reached the ceiling. It is checked before a +// peer is expanded and before each of that peer's ports, so translation never materializes +// more than maxACLsPerPolicy ACLs, and once more at the end as a backstop for the paths that +// append without a check. func checkACLBudget(npmNetPol *policies.NPMNetworkPolicy) error { - if len(npmNetPol.ACLs) > maxACLsPerPolicy { + if len(npmNetPol.ACLs) >= maxACLsPerPolicy { // The error carries the policy context and is recorded once by the caller. return fmt.Errorf("network policy %s expands past the %d rule limit: %w", npmNetPol.PolicyKey, maxACLsPerPolicy, ErrTooManyACLs) diff --git a/npm/pkg/controlplane/translation/translatePolicy_test.go b/npm/pkg/controlplane/translation/translatePolicy_test.go index 2d79ce35bed..ac1e89d148c 100644 --- a/npm/pkg/controlplane/translation/translatePolicy_test.go +++ b/npm/pkg/controlplane/translation/translatePolicy_test.go @@ -817,6 +817,25 @@ func TestIPBlockRule(t *testing.T) { } } +// TestIPBlockRuleRejectsInvalidExcept covers an ipBlock whose except is not an IPv4 CIDR. Such +// an exclusion cannot be programmed, so the translation fails rather than carrying the entry +// into the set, which would either widen the allow to the enclosing CIDR or take the whole set +// down when it is restored. The Windows datapath refuses any except before this check, so the +// case is exercised on Linux only. +func TestIPBlockRuleRejectsInvalidExcept(t *testing.T) { + if util.IsWindowsDP() { + t.Skip("the Windows datapath refuses any except on this path") + } + + for _, except := range []string{"2001:db8::/32", "not-a-cidr", "10.0.0.1", "10.0.0.0/33"} { + translatedIPSet, setInfo, err := ipBlockRule("test", defaultNS, policies.Ingress, policies.SrcMatch, 0, 0, + &networkingv1.IPBlock{CIDR: "172.17.0.0/16", Except: []string{except}}) + require.ErrorIs(t, err, ErrUnsupportedIPAddress, "except %q must be refused", except) + require.Nil(t, translatedIPSet) + require.Equal(t, policies.SetInfo{}, setInfo) + } +} + func TestPodSelector(t *testing.T) { matchType := policies.DstMatch policyKey := "test-ns/test-policy" From 5817c7a1c7f967e54c1fc65e8a75fe43da3b654b Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Tue, 8 Sep 2026 21:03:43 +0000 Subject: [PATCH 12/33] fix: [NPM] bound a rule that lists ports and no peers A rule with ports and no peers takes a path that appends one ACL per port with no peer expansion to bound it, so it could materialize an ACL for every port before the check at the end of translation refused the policy. The budget is now checked inside that loop as well, which leaves the end-of- translation check as a backstop rather than the first line of defence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../translation/parseSelector_test.go | 18 ++++++++++++++++++ .../translation/translatePolicy.go | 13 ++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/npm/pkg/controlplane/translation/parseSelector_test.go b/npm/pkg/controlplane/translation/parseSelector_test.go index 51c69986838..901f1d8d035 100644 --- a/npm/pkg/controlplane/translation/parseSelector_test.go +++ b/npm/pkg/controlplane/translation/parseSelector_test.go @@ -920,6 +920,24 @@ func TestTranslatePolicyOrdinaryPolicyWithinACLBudget(t *testing.T) { require.Less(t, len(npmNetPol.ACLs), maxACLsPerPolicy) } +// TestPortOnlyRuleBudgetStopsWithinPortLoop covers a rule that lists ports and no peers. That +// path emits one ACL per port with no peer expansion to bound it, so the budget has to be +// checked inside its loop rather than only by the backstop at the end of translation. +func TestPortOnlyRuleBudgetStopsWithinPortLoop(t *testing.T) { + portCount := maxACLsPerPolicy * 2 + ports := make([]networkingv1.NetworkPolicyPort, 0, portCount) + for i := 0; i < portCount; i++ { + p := intstr.FromInt(1 + i) + ports = append(ports, networkingv1.NetworkPolicyPort{Port: &p}) + } + + npmNetPol := policies.NewNPMNetworkPolicy("port-only", defaultNS) + err := checkOnlyPortRuleExists(true, false, false, ports, false, policies.Ingress, npmNetPol) + require.ErrorIs(t, err, ErrTooManyACLs) + require.LessOrEqual(t, len(npmNetPol.ACLs), maxACLsPerPolicy, + "a rule with only ports must stop once the budget is spent") +} + // TestPeerAndPortRuleBudgetStopsWithinPortLoop covers a single peer listing more ports than // the budget allows. One peer emits one ACL per port, so a budget checked only on entry to // peerAndPortRule would let that peer materialize every ACL before anything noticed. diff --git a/npm/pkg/controlplane/translation/translatePolicy.go b/npm/pkg/controlplane/translation/translatePolicy.go index 162b44b13ff..d0fa03d4cfe 100644 --- a/npm/pkg/controlplane/translation/translatePolicy.go +++ b/npm/pkg/controlplane/translation/translatePolicy.go @@ -851,9 +851,10 @@ func TranslatePolicy(npObj *networkingv1.NetworkPolicy, npmLiteToggle bool) (*po const maxACLsPerPolicy = 2000 // checkACLBudget reports whether the policy has reached the ceiling. It is checked before a -// peer is expanded and before each of that peer's ports, so translation never materializes -// more than maxACLsPerPolicy ACLs, and once more at the end as a backstop for the paths that -// append without a check. +// peer is expanded, before each of that peer's ports, and before each port of a port-only +// rule, so translation never materializes more than maxACLsPerPolicy ACLs on the ipset path. +// It is checked once more at the end of translation as a backstop, which covers the paths +// that append without a check, including the direct-rule path this change leaves alone. func checkACLBudget(npmNetPol *policies.NPMNetworkPolicy) error { if len(npmNetPol.ACLs) >= maxACLsPerPolicy { // The error carries the policy context and is recorded once by the caller. @@ -883,6 +884,12 @@ func checkOnlyPortRuleExists( // #1. Only Ports fields exist in rule if portRuleExists && !peerRuleExists && !allowExternal { for i := range ports { + // This path emits one ACL per port with no peer to bound it, so the budget is + // checked here too rather than leaving it to the backstop at the end. + if err := checkACLBudget(npmNetPol); err != nil { + return err + } + portKind, err := portType(ports[i]) if err != nil { return err From 2bb720223d43d5c2319b9ddec30cc8b521e6cca7 Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Tue, 8 Sep 2026 22:08:08 +0000 Subject: [PATCH 13/33] chore: [NPM] keep the new error messages bounded and conventional The two selector errors printed the whole namespaceSelector. The selector that triggers them is attacker-controlled and need not be small, so a policy that fails to translate could bury the log it was meant to explain. They now name the requirement that failed - its operator and key, or its key and value count - which is the part that identifies the problem. The translation failure the network policy controller returns also carried a bracketed tag and a capitalized sentence. It is always wrapped by the caller, so it reads as a lowercase fragment now. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controllers/v2/networkPolicyController.go | 6 +++--- npm/pkg/controlplane/translation/parseSelector.go | 13 ++++++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/npm/pkg/controlplane/controllers/v2/networkPolicyController.go b/npm/pkg/controlplane/controllers/v2/networkPolicyController.go index c09544dede3..84aac9bb9b2 100644 --- a/npm/pkg/controlplane/controllers/v2/networkPolicyController.go +++ b/npm/pkg/controlplane/controllers/v2/networkPolicyController.go @@ -308,11 +308,11 @@ func (c *NetworkPolicyController) syncAddAndUpdateNetPol(netPolObj *networkingv1 // The error is deliberately not logged or counted here: processNextWorkItem already // runs the returned error through utilruntime.HandleError and SendErrorLogAndMetric, // so recording it here as well would emit the same failure three times. The wrapped - // message carries the policy name and namespace so that single record stays specific. + // message names the policy so that single record stays specific. // // The exec time isn't relevant here, so consider a no-op. - return metrics.NoOp, fmt.Errorf("[syncAddAndUpdateNetPol] Error: failed to translate NetworkPolicy %s in namespace %s: %w", - netPolObj.Name, netPolObj.Namespace, err) + return metrics.NoOp, fmt.Errorf("translating network policy %s/%s: %w", + netPolObj.Namespace, netPolObj.Name, err) } _, policyExisted := c.rawNpSpecMap[netpolKey] diff --git a/npm/pkg/controlplane/translation/parseSelector.go b/npm/pkg/controlplane/translation/parseSelector.go index be1bbb1082e..bf389cbd5f7 100644 --- a/npm/pkg/controlplane/translation/parseSelector.go +++ b/npm/pkg/controlplane/translation/parseSelector.go @@ -157,9 +157,10 @@ func flattenNameSpaceSelector(nsSelector *metav1.LabelSelector) ([]metav1.LabelS default: // Fail closed: an unknown operator must not silently drop the requirement // and widen the selector. Kubernetes only admits In/NotIn/Exists/DoesNotExist. - // The error carries the selector context and is recorded once by the caller. - return nil, fmt.Errorf("operator %q on key %q in selector %v: %w", - req.Operator, req.Key, *nsSelector, ErrUnsupportedMatchExpressionOperator) + // The operator and key identify the requirement without copying the whole + // selector into the message, which a hostile selector could make enormous. + return nil, fmt.Errorf("operator %q on key %q: %w", + req.Operator, req.Key, ErrUnsupportedMatchExpressionOperator) } } @@ -183,8 +184,10 @@ func flattenNameSpaceSelector(nsSelector *metav1.LabelSelector) ([]metav1.LabelS combinations := 1 for _, req := range multiValueMatchExprs { if len(req.Values) > maxFlattenedNSSelectors/combinations { - return nil, fmt.Errorf("selector %v expands past the %d selector limit: %w", - *nsSelector, maxFlattenedNSSelectors, ErrTooManyFlattenedSelectors) + // Summarize rather than print the selector: the message must stay bounded + // precisely because the selector that triggers it need not be. + return nil, fmt.Errorf("key %q with %d values expands past the %d selector limit: %w", + req.Key, len(req.Values), maxFlattenedNSSelectors, ErrTooManyFlattenedSelectors) } combinations *= len(req.Values) } From 54c7fb07d5bab2ed2aa904a4ec4db25b864e4b71 Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Tue, 8 Sep 2026 22:39:45 +0000 Subject: [PATCH 14/33] fix: [NPM] let a policy land exactly on the rule ceiling The guard asked before an ACL is appended answers "is there room for one more", so it has to refuse at the ceiling. Reusing it on the finished policy asked a different question and refused a policy of exactly the ceiling, which made the effective limit one rule lower than the constant says and than the per-append guards allow. The end-of-translation backstop is now its own check, comparing the finished count against the ceiling, so a policy that lands exactly on it translates while one that overshot through an unchecked append path is still refused. Both share the refusal, so the error is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../translation/parseSelector_test.go | 29 +++++++++++++++++ .../translation/translatePolicy.go | 32 +++++++++++++------ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/npm/pkg/controlplane/translation/parseSelector_test.go b/npm/pkg/controlplane/translation/parseSelector_test.go index 901f1d8d035..6aaba4a361e 100644 --- a/npm/pkg/controlplane/translation/parseSelector_test.go +++ b/npm/pkg/controlplane/translation/parseSelector_test.go @@ -920,6 +920,35 @@ func TestTranslatePolicyOrdinaryPolicyWithinACLBudget(t *testing.T) { require.Less(t, len(npmNetPol.ACLs), maxACLsPerPolicy) } +// TestTranslatePolicyExactlyAtACLLimit guards the boundary. The per-append guard is asked +// whether there is room for one more ACL, so it must refuse at the ceiling; the check on the +// finished policy is asked whether the policy is past the ceiling, so it must admit a policy +// that lands exactly on it. Using the same comparison for both would reject a policy of +// exactly maxACLsPerPolicy rules. +func TestTranslatePolicyExactlyAtACLLimit(t *testing.T) { + // one ACL per port, plus the default drop the policy implies. + portCount := maxACLsPerPolicy - 1 + ports := make([]networkingv1.NetworkPolicyPort, 0, portCount) + for i := 0; i < portCount; i++ { + p := intstr.FromInt(1 + i) + ports = append(ports, networkingv1.NetworkPolicyPort{Port: &p}) + } + + pol := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "at-limit", Namespace: defaultNS}, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{}, + PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress}, + Ingress: []networkingv1.NetworkPolicyIngressRule{{Ports: ports}}, + }, + } + + npmNetPol, err := TranslatePolicy(pol, false) + require.NoError(t, err, "a policy landing exactly on the ceiling must translate") + require.NotNil(t, npmNetPol) + require.Len(t, npmNetPol.ACLs, maxACLsPerPolicy) +} + // TestPortOnlyRuleBudgetStopsWithinPortLoop covers a rule that lists ports and no peers. That // path emits one ACL per port with no peer expansion to bound it, so the budget has to be // checked inside its loop rather than only by the backstop at the end of translation. diff --git a/npm/pkg/controlplane/translation/translatePolicy.go b/npm/pkg/controlplane/translation/translatePolicy.go index d0fa03d4cfe..6367ef55ca9 100644 --- a/npm/pkg/controlplane/translation/translatePolicy.go +++ b/npm/pkg/controlplane/translation/translatePolicy.go @@ -834,7 +834,7 @@ func TranslatePolicy(npObj *networkingv1.NetworkPolicy, npmLiteToggle bool) (*po } } - if err := checkACLBudget(npmNetPol); err != nil { + if err := checkACLTotal(npmNetPol); err != nil { return nil, err } @@ -850,20 +850,34 @@ func TranslatePolicy(npObj *networkingv1.NetworkPolicy, npmLiteToggle bool) (*po // since a policy expanding this wide would already be unusable as iptables rules. const maxACLsPerPolicy = 2000 -// checkACLBudget reports whether the policy has reached the ceiling. It is checked before a -// peer is expanded, before each of that peer's ports, and before each port of a port-only -// rule, so translation never materializes more than maxACLsPerPolicy ACLs on the ipset path. -// It is checked once more at the end of translation as a backstop, which covers the paths -// that append without a check, including the direct-rule path this change leaves alone. +// checkACLBudget reports whether there is room for another ACL. It is checked before a peer +// is expanded, before each of that peer's ports, and before each port of a port-only rule, so +// those paths never take the policy past the ceiling. func checkACLBudget(npmNetPol *policies.NPMNetworkPolicy) error { if len(npmNetPol.ACLs) >= maxACLsPerPolicy { - // The error carries the policy context and is recorded once by the caller. - return fmt.Errorf("network policy %s expands past the %d rule limit: %w", - npmNetPol.PolicyKey, maxACLsPerPolicy, ErrTooManyACLs) + return tooManyACLs(npmNetPol) } return nil } +// checkACLTotal reports whether the finished policy is past the ceiling. It is the backstop +// for the paths that append without asking for room first, including the direct-rule path +// this change leaves alone. It admits a policy that lands exactly on the ceiling, which +// checkACLBudget cannot do because it is asked before the ACL exists. +func checkACLTotal(npmNetPol *policies.NPMNetworkPolicy) error { + if len(npmNetPol.ACLs) > maxACLsPerPolicy { + return tooManyACLs(npmNetPol) + } + return nil +} + +// tooManyACLs builds the refusal. The error carries the policy context and is recorded once +// by the caller. +func tooManyACLs(npmNetPol *policies.NPMNetworkPolicy) error { + return fmt.Errorf("network policy %s expands past the %d rule limit: %w", + npmNetPol.PolicyKey, maxACLsPerPolicy, ErrTooManyACLs) +} + func checkForNamedPortType(npmNetPol *policies.NPMNetworkPolicy, portKind netpolPortType, npmLiteToggle bool, direction policies.Direction, port *networkingv1.NetworkPolicyPort, cidr string) error { if npmLiteToggle && portKind == namedPortType { return fmt.Errorf("named port not supported in policy %s (namespace: %s, direction: %s, cidr: %s, port: %v, protocol: %v): %w", From 10cb1a717d3f3c309ab957beb0781bdce8263aa4 Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Thu, 10 Sep 2026 22:29:57 +0000 Subject: [PATCH 15/33] fix: [NPM] bound the matches a namespaceSelector expands into Compiling a multi-value NotIn as one conjunction keeps it out of both existing bounds: it stays a single selector, so the flattened-selector count does not see it, and it produces a single rule, so the per-policy rule budget does not either. Every value still becomes its own ipset and its own condition on that rule, so a valid policy listing a long NotIn could still create thousands of sets and one enormous rule. The matches a selector expands into are now counted before anything is allocated, against the same ceiling used for the selector count. A NotIn contributes one per value, In one per branch, and Exists and DoesNotExist one each. Selectors on the bound still translate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controlplane/translation/parseSelector.go | 27 +++++++++ .../translation/parseSelector_test.go | 55 +++++++++++++++++++ .../translation/translatePolicy.go | 5 ++ 3 files changed, 87 insertions(+) diff --git a/npm/pkg/controlplane/translation/parseSelector.go b/npm/pkg/controlplane/translation/parseSelector.go index bf389cbd5f7..b911378595c 100644 --- a/npm/pkg/controlplane/translation/parseSelector.go +++ b/npm/pkg/controlplane/translation/parseSelector.go @@ -14,6 +14,13 @@ import ( // an alphanumeric character (e.g. 'MyValue', or 'my_value', or '12345', regex used for validation is '(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?' var validLabelRegex = regexp.MustCompile("(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?") +// maxSelectorMatches bounds how many set matches a single namespaceSelector may expand into. +// Each match becomes its own IPSet and its own condition on the rule the selector produces, and +// a multi-value NotIn contributes one per value while staying in a single selector, so it is +// counted by neither maxFlattenedNSSelectors nor the per-policy rule budget. The bound is the +// same ceiling used for the selector count, and is far above any workable selector. +const maxSelectorMatches = maxFlattenedNSSelectors + // maxFlattenedNSSelectors caps how many labelSelectors a single namespaceSelector may be // flattened into. Flattening multi-value In requirements produces the Cartesian product of // their values, and each resulting selector is deep-copied and later turned into its own @@ -81,6 +88,26 @@ func flattenNameSpaceSelector(nsSelector *metav1.LabelSelector) ([]metav1.LabelS return []metav1.LabelSelector{*nsSelector}, nil } + // Bound how far this selector expands, before anything is allocated. A multi-value NotIn + // stays inside a single selector, so it is invisible to both the selector-count bound + // further down and the per-policy rule budget, yet every one of its values becomes its own + // IPSet and its own condition on one rule. Counting the matches the selector will produce + // is what catches that. + matches := len(nsSelector.MatchLabels) + for _, req := range nsSelector.MatchExpressions { + if req.Operator == metav1.LabelSelectorOpNotIn { + // each excluded value is carried as its own negated match + matches += len(req.Values) + continue + } + // In contributes one match per branch; Exists and DoesNotExist one each + matches++ + } + if matches > maxSelectorMatches { + return nil, fmt.Errorf("selector expands into %d matches, past the %d limit: %w", + matches, maxSelectorMatches, ErrTooManySelectorMatches) + } + // create a baseSelector which needs to be same across all // new labelSelectors baseSelector := &metav1.LabelSelector{ diff --git a/npm/pkg/controlplane/translation/parseSelector_test.go b/npm/pkg/controlplane/translation/parseSelector_test.go index 6aaba4a361e..6d3aea2d1a6 100644 --- a/npm/pkg/controlplane/translation/parseSelector_test.go +++ b/npm/pkg/controlplane/translation/parseSelector_test.go @@ -984,3 +984,58 @@ func TestPeerAndPortRuleBudgetStopsWithinPortLoop(t *testing.T) { require.LessOrEqual(t, len(npmNetPol.ACLs), maxACLsPerPolicy, "the port loop must stop once the budget is spent instead of emitting an ACL for every port") } + +// TestNotInValuesAreBounded covers a long NotIn list. Compiling it as one conjunction keeps it +// out of the flattened-selector count and out of the rule budget, because it stays a single +// selector producing a single rule, but every value still becomes its own IPSet and its own +// condition on that rule. The match bound is what stops it. +func TestNotInValuesAreBounded(t *testing.T) { + values := make([]string, 0, maxSelectorMatches+1) + for i := 0; i <= maxSelectorMatches; i++ { + values = append(values, fmt.Sprintf("v%d", i)) + } + + selector := &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + {Key: tenantLabelKey, Operator: metav1.LabelSelectorOpNotIn, Values: values}, + }, + } + + flattened, err := flattenNameSpaceSelector(selector) + require.ErrorIs(t, err, ErrTooManySelectorMatches, + "a NotIn list past the match bound must be refused") + require.Nil(t, flattened) + + // the same policy is refused end to end, so no partial rules are installed + pol := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "wide-notin", Namespace: defaultNS}, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{}, + PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress}, + Ingress: []networkingv1.NetworkPolicyIngressRule{{ + From: []networkingv1.NetworkPolicyPeer{{NamespaceSelector: selector}}, + }}, + }, + } + npmNetPol, err := TranslatePolicy(pol, false) + require.ErrorIs(t, err, ErrTooManySelectorMatches) + require.Nil(t, npmNetPol) +} + +// TestNotInValuesAtTheBoundAreAccepted keeps the bound from rejecting a selector that sits +// exactly on it, and guards the ordinary small NotIn that real policies use. +func TestNotInValuesAtTheBoundAreAccepted(t *testing.T) { + values := make([]string, 0, maxSelectorMatches) + for i := 0; i < maxSelectorMatches; i++ { + values = append(values, fmt.Sprintf("v%d", i)) + } + + flattened, err := flattenNameSpaceSelector(&metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + {Key: tenantLabelKey, Operator: metav1.LabelSelectorOpNotIn, Values: values}, + }, + }) + require.NoError(t, err, "a selector exactly on the bound must translate") + require.Len(t, flattened, 1, "a NotIn stays a single conjunction") + require.Len(t, flattened[0].MatchExpressions, maxSelectorMatches) +} diff --git a/npm/pkg/controlplane/translation/translatePolicy.go b/npm/pkg/controlplane/translation/translatePolicy.go index 6367ef55ca9..bda8c47e8d5 100644 --- a/npm/pkg/controlplane/translation/translatePolicy.go +++ b/npm/pkg/controlplane/translation/translatePolicy.go @@ -47,6 +47,11 @@ var ( // requirements would produce more labelSelectors than NPM is willing to translate. The count is // the product of the value counts, so it grows exponentially with the number of such requirements. ErrTooManyFlattenedSelectors = errors.New("namespaceSelector expands into too many label selectors") + // ErrTooManySelectorMatches is returned when a namespaceSelector expands into more set matches + // than NPM is willing to translate. A multi-value NotIn contributes one match per value while + // staying in a single selector, so it is counted by neither the flattened-selector bound nor the + // per-policy rule budget, yet each match becomes its own IPSet and its own condition on a rule. + ErrTooManySelectorMatches = errors.New("namespaceSelector expands into too many set matches") // ErrTooManyACLs is returned when a NetworkPolicy translates into more ACLs than NPM is // willing to program. ACL count multiplies rather than adds: flattened selector branches are // emitted per port, summed across peers and rules, so bounding selectors alone is not enough. From e26f3d017bc9de2123d6fbf6e2d3395d5bc086c5 Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Thu, 10 Sep 2026 23:28:43 +0000 Subject: [PATCH 16/33] fix: [NPM] bound a selector that carries only matchLabels The match bound sat below the shortcut that returns a selector carrying no match expressions, so a selector made only of matchLabels skipped it even though each of those labels becomes its own ipset and its own condition on the rule. The bound is now applied before that shortcut. Also pins the ipBlock member packing now that except CIDRs are canonicalized first. Canonicalizing can turn an except into one of the two halves that 0.0.0.0/0 is split into, which takes the branch that rewrites an existing member and shortens the list, so the test covers a split-half except first, last, between two ordinary excepts, both halves at once, and a non-canonical all-addresses block, and asserts every other except still survives. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controlplane/translation/parseSelector.go | 18 +++--- .../translation/parseSelector_test.go | 22 +++++++ .../translation/translatePolicy_test.go | 58 +++++++++++++++++++ 3 files changed, 89 insertions(+), 9 deletions(-) diff --git a/npm/pkg/controlplane/translation/parseSelector.go b/npm/pkg/controlplane/translation/parseSelector.go index b911378595c..e2c715ee771 100644 --- a/npm/pkg/controlplane/translation/parseSelector.go +++ b/npm/pkg/controlplane/translation/parseSelector.go @@ -84,15 +84,11 @@ func flattenNameSpaceSelector(nsSelector *metav1.LabelSelector) ([]metav1.LabelS return []metav1.LabelSelector{}, nil } - if len(nsSelector.MatchExpressions) == 0 { - return []metav1.LabelSelector{*nsSelector}, nil - } - - // Bound how far this selector expands, before anything is allocated. A multi-value NotIn - // stays inside a single selector, so it is invisible to both the selector-count bound - // further down and the per-policy rule budget, yet every one of its values becomes its own - // IPSet and its own condition on one rule. Counting the matches the selector will produce - // is what catches that. + // Bound how many matches this selector produces, before anything is allocated and before + // the matchLabels-only shortcut below, since those labels each become a match too. A + // multi-value NotIn stays inside a single selector, so it is invisible to both the + // selector-count bound further down and the per-policy rule budget, yet every one of its + // values becomes its own IPSet and its own condition on one rule. matches := len(nsSelector.MatchLabels) for _, req := range nsSelector.MatchExpressions { if req.Operator == metav1.LabelSelectorOpNotIn { @@ -108,6 +104,10 @@ func flattenNameSpaceSelector(nsSelector *metav1.LabelSelector) ([]metav1.LabelS matches, maxSelectorMatches, ErrTooManySelectorMatches) } + if len(nsSelector.MatchExpressions) == 0 { + return []metav1.LabelSelector{*nsSelector}, nil + } + // create a baseSelector which needs to be same across all // new labelSelectors baseSelector := &metav1.LabelSelector{ diff --git a/npm/pkg/controlplane/translation/parseSelector_test.go b/npm/pkg/controlplane/translation/parseSelector_test.go index 6d3aea2d1a6..58e5dd8278b 100644 --- a/npm/pkg/controlplane/translation/parseSelector_test.go +++ b/npm/pkg/controlplane/translation/parseSelector_test.go @@ -1039,3 +1039,25 @@ func TestNotInValuesAtTheBoundAreAccepted(t *testing.T) { require.Len(t, flattened, 1, "a NotIn stays a single conjunction") require.Len(t, flattened[0].MatchExpressions, maxSelectorMatches) } + +// TestMatchLabelsOnlySelectorIsBounded covers a selector that carries only matchLabels. It +// takes a shortcut past the expression handling, but each label still becomes its own match, +// so the bound has to be applied before that shortcut. +func TestMatchLabelsOnlySelectorIsBounded(t *testing.T) { + labels := make(map[string]string, maxSelectorMatches+1) + for i := 0; i <= maxSelectorMatches; i++ { + labels[fmt.Sprintf("key%d", i)] = "v" + } + + flattened, err := flattenNameSpaceSelector(&metav1.LabelSelector{MatchLabels: labels}) + require.ErrorIs(t, err, ErrTooManySelectorMatches, + "a matchLabels-only selector past the bound must be refused") + require.Nil(t, flattened) + + // an ordinary selector is untouched + ok, err := flattenNameSpaceSelector(&metav1.LabelSelector{ + MatchLabels: map[string]string{"team": teamBlueValue}, + }) + require.NoError(t, err) + require.Len(t, ok, 1) +} diff --git a/npm/pkg/controlplane/translation/translatePolicy_test.go b/npm/pkg/controlplane/translation/translatePolicy_test.go index ac1e89d148c..209b1cf62ba 100644 --- a/npm/pkg/controlplane/translation/translatePolicy_test.go +++ b/npm/pkg/controlplane/translation/translatePolicy_test.go @@ -30,6 +30,7 @@ const ( blockedLabelKey string = "blocked" teamBlueValue string = "blue" lowerHalfNomatch string = "0.0.0.0/1 nomatch" + exceptedClassA string = "200.0.0.0/8" ingressName string = "ingress" egressName string = "egress" ) @@ -4080,3 +4081,60 @@ func TestTranslatePolicyNegationOnlyOperators(t *testing.T) { } } } + +// TestIPBlockExceptCanonicalizationKeepsEveryExcept locks the member packing now that except +// CIDRs are canonicalized first. Canonicalizing can turn an except into one of the two halves +// that 0.0.0.0/0 is split into, which takes a different branch of the packing loop and shortens +// the member list, so every other except must still survive that, whatever order they arrive in. +func TestIPBlockExceptCanonicalizationKeepsEveryExcept(t *testing.T) { + if util.IsWindowsDP() { + t.Skip("the Windows datapath refuses any except on this path") + } + + tests := []struct { + name string + cidr string + except []string + want []string + }{ + { + name: "except canonicalizes onto the lower half, listed last", + cidr: "0.0.0.0/0", + except: []string{exceptedClassA, "10.0.0.0/1"}, + want: []string{lowerHalfNomatch, "128.0.0.0/1", exceptedClassA + " nomatch"}, + }, + { + name: "same excepts in the other order", + cidr: "0.0.0.0/0", + except: []string{"10.0.0.0/1", exceptedClassA}, + want: []string{lowerHalfNomatch, "128.0.0.0/1", exceptedClassA + " nomatch"}, + }, + { + name: "a split-half except between two ordinary ones", + cidr: "0.0.0.0/0", + except: []string{exceptedClassA, "10.0.0.0/1", "9.0.0.0/8"}, + want: []string{lowerHalfNomatch, "128.0.0.0/1", exceptedClassA + " nomatch", "9.0.0.0/8 nomatch"}, + }, + { + name: "both halves reached by canonicalization", + cidr: "0.0.0.0/0", + except: []string{exceptedClassA, "250.0.0.0/1", "10.0.0.0/1"}, + want: []string{lowerHalfNomatch, "128.0.0.0/1 nomatch", exceptedClassA + " nomatch"}, + }, + { + name: "a non-canonical all-addresses block behaves the same", + cidr: "10.0.0.0/0", + except: []string{exceptedClassA, "10.0.0.0/1"}, + want: []string{lowerHalfNomatch, "128.0.0.0/1", exceptedClassA + " nomatch"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + set, err := ipBlockIPSet("p", defaultNS, policies.Ingress, 0, 0, + &networkingv1.IPBlock{CIDR: tt.cidr, Except: tt.except}) + require.NoError(t, err) + require.Equal(t, tt.want, set.Members) + }) + } +} From 63cd697bdd1435e809b2c3045bec4cac4c3f290e Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Fri, 11 Sep 2026 16:35:31 +0000 Subject: [PATCH 17/33] fix: [NPM] bound a selector across its branches and keep the rule ceiling exact Two bounds were checkable individually but not together. A multi-value In repeats the whole selector once per value, so a selector can sit under both the match bound and the branch bound while their product is enormous: 991 matchLabels with nine two-value In requirements is 1000 matches across 512 branches, and the translator materializes an ipset and a set reference for each before the policy's rule budget is consulted. The product is now bounded too. The branch count is still checked on its own terms first, so a selector that merely fans out too far reports that rather than the total. The match count also missed the all-namespaces anchor that a selector matching only negatively is given, so such a selector could produce one match more than the bound allowed. Separately, the default drop a policy implies was appended before the check at the end of translation, so a policy that filled the budget with allow rules landed one or two ACLs past the ceiling before being refused. The per-append guard now holds back a slot per direction, so the ceiling is not exceeded. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controlplane/translation/parseSelector.go | 47 ++++++++++++- .../translation/parseSelector_test.go | 67 +++++++++++++++++-- .../translation/translatePolicy.go | 10 ++- 3 files changed, 112 insertions(+), 12 deletions(-) diff --git a/npm/pkg/controlplane/translation/parseSelector.go b/npm/pkg/controlplane/translation/parseSelector.go index e2c715ee771..0e67c1e6a3e 100644 --- a/npm/pkg/controlplane/translation/parseSelector.go +++ b/npm/pkg/controlplane/translation/parseSelector.go @@ -14,6 +14,13 @@ import ( // an alphanumeric character (e.g. 'MyValue', or 'my_value', or '12345', regex used for validation is '(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?' var validLabelRegex = regexp.MustCompile("(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?") +// maxTotalSelectorMatches bounds the set matches a namespaceSelector produces across every +// branch it fans out into. A multi-value In repeats the whole selector once per value, so the +// cost is the branch count multiplied by the matches in each branch; bounding either factor on +// its own leaves a wide selector repeated across many branches unbounded, and the translator +// materializes an IPSet and a SetInfo for each before the per-policy rule budget is consulted. +const maxTotalSelectorMatches = 10000 + // maxSelectorMatches bounds how many set matches a single namespaceSelector may expand into. // Each match becomes its own IPSet and its own condition on the rule the selector produces, and // a multi-value NotIn contributes one per value while staying in a single selector, so it is @@ -90,19 +97,53 @@ func flattenNameSpaceSelector(nsSelector *metav1.LabelSelector) ([]metav1.LabelS // selector-count bound further down and the per-policy rule budget, yet every one of its // values becomes its own IPSet and its own condition on one rule. matches := len(nsSelector.MatchLabels) + branches := 1 + hasPositiveMatch := len(nsSelector.MatchLabels) > 0 for _, req := range nsSelector.MatchExpressions { - if req.Operator == metav1.LabelSelectorOpNotIn { + switch req.Operator { + case metav1.LabelSelectorOpNotIn: // each excluded value is carried as its own negated match matches += len(req.Values) - continue + case metav1.LabelSelectorOpIn: + // one match per branch, and a multi-value In fans out into branches + matches++ + hasPositiveMatch = true + if len(req.Values) > 1 { + // the branch count is bounded on its own terms first, so a selector that + // fans out too far still reports that rather than the total below. + // Divide rather than multiply so the product cannot overflow. + if len(req.Values) > maxFlattenedNSSelectors/branches { + return nil, fmt.Errorf("key %q with %d values expands past the %d selector limit: %w", + req.Key, len(req.Values), maxFlattenedNSSelectors, ErrTooManyFlattenedSelectors) + } + branches *= len(req.Values) + } + case metav1.LabelSelectorOpExists: + matches++ + hasPositiveMatch = true + case metav1.LabelSelectorOpDoesNotExist: + matches++ + default: + // an unknown operator, which the loop below rejects + matches++ } - // In contributes one match per branch; Exists and DoesNotExist one each + } + if !hasPositiveMatch { + // parseNSSelector anchors a selector that matches only negatively with the + // all-namespaces set, so that match counts too matches++ } if matches > maxSelectorMatches { return nil, fmt.Errorf("selector expands into %d matches, past the %d limit: %w", matches, maxSelectorMatches, ErrTooManySelectorMatches) } + // Each branch repeats every match, so the cost is the product rather than either factor. + // The branch count alone is bounded further down and the rule count by the policy budget, + // but neither sees a wide selector repeated across many branches. + if matches > maxTotalSelectorMatches/branches { + return nil, fmt.Errorf("selector expands into %d branches of %d matches, past the %d total match limit: %w", + branches, matches, maxTotalSelectorMatches, ErrTooManySelectorMatches) + } if len(nsSelector.MatchExpressions) == 0 { return []metav1.LabelSelector{*nsSelector}, nil diff --git a/npm/pkg/controlplane/translation/parseSelector_test.go b/npm/pkg/controlplane/translation/parseSelector_test.go index 58e5dd8278b..e1b03045d87 100644 --- a/npm/pkg/controlplane/translation/parseSelector_test.go +++ b/npm/pkg/controlplane/translation/parseSelector_test.go @@ -926,8 +926,9 @@ func TestTranslatePolicyOrdinaryPolicyWithinACLBudget(t *testing.T) { // that lands exactly on it. Using the same comparison for both would reject a policy of // exactly maxACLsPerPolicy rules. func TestTranslatePolicyExactlyAtACLLimit(t *testing.T) { - // one ACL per port, plus the default drop the policy implies. - portCount := maxACLsPerPolicy - 1 + // the budget holds back a slot for the default drop the policy implies, so this is the + // widest a policy can get: every port emits an ACL and the drop still fits under the ceiling + portCount := maxACLsPerPolicy - reservedDropACLs ports := make([]networkingv1.NetworkPolicyPort, 0, portCount) for i := 0; i < portCount; i++ { p := intstr.FromInt(1 + i) @@ -944,9 +945,11 @@ func TestTranslatePolicyExactlyAtACLLimit(t *testing.T) { } npmNetPol, err := TranslatePolicy(pol, false) - require.NoError(t, err, "a policy landing exactly on the ceiling must translate") + require.NoError(t, err, "a policy at the widest the budget allows must translate") require.NotNil(t, npmNetPol) - require.Len(t, npmNetPol.ACLs, maxACLsPerPolicy) + require.Len(t, npmNetPol.ACLs, portCount+1, "every port plus the default drop") + require.LessOrEqual(t, len(npmNetPol.ACLs), maxACLsPerPolicy, + "the drop must never take the policy past the ceiling") } // TestPortOnlyRuleBudgetStopsWithinPortLoop covers a rule that lists ports and no peers. That @@ -1025,8 +1028,10 @@ func TestNotInValuesAreBounded(t *testing.T) { // TestNotInValuesAtTheBoundAreAccepted keeps the bound from rejecting a selector that sits // exactly on it, and guards the ordinary small NotIn that real policies use. func TestNotInValuesAtTheBoundAreAccepted(t *testing.T) { - values := make([]string, 0, maxSelectorMatches) - for i := 0; i < maxSelectorMatches; i++ { + // one short of the bound: the selector matches only negatively, so parseNSSelector + // anchors it with the all-namespaces set and that match counts too + values := make([]string, 0, maxSelectorMatches-1) + for i := 0; i < maxSelectorMatches-1; i++ { values = append(values, fmt.Sprintf("v%d", i)) } @@ -1037,7 +1042,7 @@ func TestNotInValuesAtTheBoundAreAccepted(t *testing.T) { }) require.NoError(t, err, "a selector exactly on the bound must translate") require.Len(t, flattened, 1, "a NotIn stays a single conjunction") - require.Len(t, flattened[0].MatchExpressions, maxSelectorMatches) + require.Len(t, flattened[0].MatchExpressions, maxSelectorMatches-1) } // TestMatchLabelsOnlySelectorIsBounded covers a selector that carries only matchLabels. It @@ -1061,3 +1066,51 @@ func TestMatchLabelsOnlySelectorIsBounded(t *testing.T) { require.NoError(t, err) require.Len(t, ok, 1) } + +// TestSelectorBranchesTimesMatchesIsBounded covers a selector that stays under both the match +// bound and the branch bound yet multiplies them together. Each branch repeats every match, and +// the translator materializes an IPSet and a SetInfo per match before the policy's rule budget +// is consulted, so the product is what has to be bounded. +func TestSelectorBranchesTimesMatchesIsBounded(t *testing.T) { + // 991 labels plus nine two-value In requirements: 1000 matches per branch, 512 branches + labels := make(map[string]string, 991) + for i := 0; i < 991; i++ { + labels[fmt.Sprintf("key%d", i)] = "v" + } + reqs := make([]metav1.LabelSelectorRequirement, 0, 9) + for i := 0; i < 9; i++ { + reqs = append(reqs, metav1.LabelSelectorRequirement{ + Key: fmt.Sprintf("in%d", i), + Operator: metav1.LabelSelectorOpIn, + Values: []string{"a", "b"}, + }) + } + + selector := &metav1.LabelSelector{MatchLabels: labels, MatchExpressions: reqs} + + // each factor on its own is within its bound + require.LessOrEqual(t, len(labels)+len(reqs), maxSelectorMatches) + require.LessOrEqual(t, 1<= maxACLsPerPolicy { + if len(npmNetPol.ACLs) >= maxACLsPerPolicy-reservedDropACLs { return tooManyACLs(npmNetPol) } return nil From 53888075a6eb2220a332c3a334238ef29606cc83 Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Fri, 11 Sep 2026 21:09:26 +0000 Subject: [PATCH 18/33] fix: [NPM] account for default drops at the ACL boundary Check the budget immediately before default-drop and allow-all appends instead of reserving two slots throughout translation. Preserve exact-limit policies for either direction and both direction orders. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 175c523d-ad1c-4e31-986d-7c9940c6c18c --- .../translation/acl_budget_test.go | 91 +++++++++++++++++++ .../translation/parseSelector_test.go | 10 +- .../translation/translatePolicy.go | 35 +++++-- 3 files changed, 120 insertions(+), 16 deletions(-) create mode 100644 npm/pkg/controlplane/translation/acl_budget_test.go diff --git a/npm/pkg/controlplane/translation/acl_budget_test.go b/npm/pkg/controlplane/translation/acl_budget_test.go new file mode 100644 index 00000000000..dc542a604f1 --- /dev/null +++ b/npm/pkg/controlplane/translation/acl_budget_test.go @@ -0,0 +1,91 @@ +package translation + +import ( + "testing" + + "github.com/Azure/azure-container-networking/npm/pkg/dataplane/policies" + "github.com/stretchr/testify/require" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +func TestPolicyACLBudgetDirections(t *testing.T) { + makePorts := func(count int) []networkingv1.NetworkPolicyPort { + ports := make([]networkingv1.NetworkPolicyPort, count) + for i := range ports { + port := intstr.FromInt(i + 1) + ports[i] = networkingv1.NetworkPolicyPort{Port: &port} + } + return ports + } + for _, dual := range []bool{false, true} { + for _, direction := range []networkingv1.PolicyType{networkingv1.PolicyTypeIngress, networkingv1.PolicyTypeEgress} { + for _, over := range []bool{false, true} { + name := string(direction) + if dual { + name += "-dual" + } + if over { + name += "-over" + } + t.Run(name, func(t *testing.T) { + portCount := maxACLsPerPolicy - 1 + policyTypes := []networkingv1.PolicyType{direction} + if dual { + portCount -= 2 // One allow and one drop for the other direction. + } + if over { + portCount++ + } + policy := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "boundary", Namespace: defaultNS}, + Spec: networkingv1.NetworkPolicySpec{ + PolicyTypes: policyTypes, + }, + } + if direction == networkingv1.PolicyTypeIngress { + policy.Spec.Ingress = []networkingv1.NetworkPolicyIngressRule{{Ports: makePorts(portCount)}} + if dual { + policy.Spec.PolicyTypes = append(policy.Spec.PolicyTypes, networkingv1.PolicyTypeEgress) + policy.Spec.Egress = []networkingv1.NetworkPolicyEgressRule{{Ports: makePorts(1)}} + } + } else { + policy.Spec.Egress = []networkingv1.NetworkPolicyEgressRule{{Ports: makePorts(portCount)}} + if dual { + policy.Spec.PolicyTypes = append(policy.Spec.PolicyTypes, networkingv1.PolicyTypeIngress) + policy.Spec.Ingress = []networkingv1.NetworkPolicyIngressRule{{Ports: makePorts(1)}} + } + } + got, err := TranslatePolicy(policy, false) + if over { + require.ErrorIs(t, err, ErrTooManyACLs) + require.Nil(t, got) + } else { + require.NoError(t, err) + require.Len(t, got.ACLs, maxACLsPerPolicy) + } + }) + } + } + } +} + +func TestDefaultDropDoesNotExceedACLBudget(t *testing.T) { + for _, direction := range []policies.Direction{policies.Ingress, policies.Egress} { + t.Run(string(direction), func(t *testing.T) { + policy := policies.NewNPMNetworkPolicy("full", defaultNS) + for i := 0; i < maxACLsPerPolicy; i++ { + policy.ACLs = append(policy.ACLs, policies.NewACLPolicy(policies.Allowed, direction)) + } + var err error + if direction == policies.Ingress { + err = ingressPolicy(policy, "full", nil, false) + } else { + err = egressPolicy(policy, "full", nil, false) + } + require.ErrorIs(t, err, ErrTooManyACLs) + require.Len(t, policy.ACLs, maxACLsPerPolicy) + }) + } +} diff --git a/npm/pkg/controlplane/translation/parseSelector_test.go b/npm/pkg/controlplane/translation/parseSelector_test.go index e1b03045d87..e6986fdd581 100644 --- a/npm/pkg/controlplane/translation/parseSelector_test.go +++ b/npm/pkg/controlplane/translation/parseSelector_test.go @@ -926,9 +926,8 @@ func TestTranslatePolicyOrdinaryPolicyWithinACLBudget(t *testing.T) { // that lands exactly on it. Using the same comparison for both would reject a policy of // exactly maxACLsPerPolicy rules. func TestTranslatePolicyExactlyAtACLLimit(t *testing.T) { - // the budget holds back a slot for the default drop the policy implies, so this is the - // widest a policy can get: every port emits an ACL and the drop still fits under the ceiling - portCount := maxACLsPerPolicy - reservedDropACLs + // One ACL per port, plus the required default drop. + portCount := maxACLsPerPolicy - 1 ports := make([]networkingv1.NetworkPolicyPort, 0, portCount) for i := 0; i < portCount; i++ { p := intstr.FromInt(1 + i) @@ -945,11 +944,10 @@ func TestTranslatePolicyExactlyAtACLLimit(t *testing.T) { } npmNetPol, err := TranslatePolicy(pol, false) - require.NoError(t, err, "a policy at the widest the budget allows must translate") + require.NoError(t, err, "a policy exactly at the ceiling must translate") require.NotNil(t, npmNetPol) require.Len(t, npmNetPol.ACLs, portCount+1, "every port plus the default drop") - require.LessOrEqual(t, len(npmNetPol.ACLs), maxACLsPerPolicy, - "the drop must never take the policy past the ceiling") + require.Len(t, npmNetPol.ACLs, maxACLsPerPolicy) } // TestPortOnlyRuleBudgetStopsWithinPortLoop covers a rule that lists ports and no peers. That diff --git a/npm/pkg/controlplane/translation/translatePolicy.go b/npm/pkg/controlplane/translation/translatePolicy.go index af2ff8706b8..52e8ba474e8 100644 --- a/npm/pkg/controlplane/translation/translatePolicy.go +++ b/npm/pkg/controlplane/translation/translatePolicy.go @@ -542,6 +542,9 @@ func translateRule(npmNetPol *policies.NPMNetworkPolicy, if npmLiteToggle { return ErrUnsupportedNonCIDR } + if err := checkACLBudget(npmNetPol); err != nil { + return err + } acl := policies.NewACLPolicy(policies.Allowed, direction) ruleIPSets, allowAllInternalSetInfo := allowAllInternal(matchType) npmNetPol.RuleIPSets = append(npmNetPol.RuleIPSets, ruleIPSets) @@ -688,12 +691,18 @@ func ingressPolicy(npmNetPol *policies.NPMNetworkPolicy, netPolName string, ingr // #1. Allow all traffic from both internal and external. // In yaml file, it is specified with '{}'. if isAllowAllToIngress(ingress) { + if err := checkACLBudget(npmNetPol); err != nil { + return err + } allowAllPolicy(npmNetPol, policies.Ingress) return nil } // #2. If ingress is nil (in yaml file, it is specified with '[]'), it means "Deny all" - it does not allow receiving any traffic from others. if ingress == nil { + if err := checkACLBudget(npmNetPol); err != nil { + return err + } // Except for allow all traffic case in #1, the rest of them should have default drop rules. dropACL := defaultDropACL(policies.Ingress) npmNetPol.ACLs = append(npmNetPol.ACLs, dropACL) @@ -708,6 +717,9 @@ func ingressPolicy(npmNetPol *policies.NPMNetworkPolicy, netPolName string, ingr } } // Except for allow all traffic case in #1, the rest of them should have default drop rules. + if err := checkACLBudget(npmNetPol); err != nil { + return err + } dropACL := defaultDropACL(policies.Ingress) npmNetPol.ACLs = append(npmNetPol.ACLs, dropACL) return nil @@ -731,12 +743,18 @@ func egressPolicy(npmNetPol *policies.NPMNetworkPolicy, netPolName string, egres // #1. Allow all traffic to both internal and external. // In yaml file, it is specified with '{}'. if isAllowAllToEgress(egress) { + if err := checkACLBudget(npmNetPol); err != nil { + return err + } allowAllPolicy(npmNetPol, policies.Egress) return nil } // #2. If egress is nil (in yaml file, it is specified with '[]'), it means "Deny all" - it does not allow sending traffic to others. if egress == nil { + if err := checkACLBudget(npmNetPol); err != nil { + return err + } // Except for allow all traffic case in #1, the rest of them should have default drop rules. dropACL := defaultDropACL(policies.Egress) npmNetPol.ACLs = append(npmNetPol.ACLs, dropACL) @@ -754,6 +772,9 @@ func egressPolicy(npmNetPol *policies.NPMNetworkPolicy, netPolName string, egres // #3. Except for allow all traffic case in #1, the rest of them should have default drop rules. // Add drop ACL to drop the rest of traffic which is not specified in Egress Spec. + if err := checkACLBudget(npmNetPol); err != nil { + return err + } dropACL := defaultDropACL(policies.Egress) npmNetPol.ACLs = append(npmNetPol.ACLs, dropACL) return nil @@ -855,17 +876,11 @@ func TranslatePolicy(npObj *networkingv1.NetworkPolicy, npmLiteToggle bool) (*po // since a policy expanding this wide would already be unusable as iptables rules. const maxACLsPerPolicy = 2000 -// reservedDropACLs is what the per-append guard holds back for the default drop a policy still -// needs after its rules are translated, one per direction. Without the reservation a policy -// that filled the budget with allow rules would append its drop on top and land one or two ACLs -// past the ceiling before the check at the end of translation refused it. -const reservedDropACLs = 2 - -// checkACLBudget reports whether there is room for another ACL. It is checked before a peer -// is expanded, before each of that peer's ports, and before each port of a port-only rule, so -// those paths never take the policy past the ceiling, including the default drop still to come. +// checkACLBudget checks room before an ACL is added, including default drops. +// Unlike reserving a fixed number of slots, this admits an exact-limit policy +// regardless of which directions have already been translated. func checkACLBudget(npmNetPol *policies.NPMNetworkPolicy) error { - if len(npmNetPol.ACLs) >= maxACLsPerPolicy-reservedDropACLs { + if len(npmNetPol.ACLs) >= maxACLsPerPolicy { return tooManyACLs(npmNetPol) } return nil From cf44fd56567baa05de0cce38404f3c189391d2ea Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Fri, 11 Sep 2026 22:13:55 +0000 Subject: [PATCH 19/33] fix: [NPM] distinguish aggregate namespace membership Use a v2-only aggregate name outside label-derived identities and update both membership producers and selector consumers. Preserve unsupported-address handling on Windows. Cover ordinary label keys with the previous aggregate spelling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 175c523d-ad1c-4e31-986d-7c9940c6c18c --- .../controllers/v2/namespaceController.go | 4 +-- .../controllers/v2/networkPolicyController.go | 1 + .../v2/networkPolicyController_test.go | 11 +++++-- .../controllers/v2/podController.go | 2 +- .../translation/namespace_anchor_test.go | 29 +++++++++++++++++ .../controlplane/translation/parseSelector.go | 6 ++-- .../translation/translatePolicy.go | 9 +++--- .../translation/translatePolicy_test.go | 32 +++++++++---------- .../policies/policymanager_linux_test.go | 4 +-- npm/util/const.go | 11 ++++--- 10 files changed, 74 insertions(+), 35 deletions(-) create mode 100644 npm/pkg/controlplane/translation/namespace_anchor_test.go diff --git a/npm/pkg/controlplane/controllers/v2/namespaceController.go b/npm/pkg/controlplane/controllers/v2/namespaceController.go index 654609ced2f..eceb9d76a65 100644 --- a/npm/pkg/controlplane/controllers/v2/namespaceController.go +++ b/npm/pkg/controlplane/controllers/v2/namespaceController.go @@ -465,12 +465,12 @@ func (nsc *NamespaceController) cleanDeletedNamespace(cachedNsKey string) error cachedNsObj.RemoveLabelsWithKey(nsLabelKey) } - allNamespacesSet := ipsets.NewIPSetMetadata(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace) + allNamespacesSet := ipsets.NewIPSetMetadata(util.KubeAllNamespacesFlagV2, ipsets.KeyLabelOfNamespace) toBeDeletedCachedKey := []*ipsets.IPSetMetadata{ipsets.NewIPSetMetadata(cachedNsKey, ipsets.Namespace)} // Delete the namespace from all-namespace ipset list. if err = nsc.dp.RemoveFromList(allNamespacesSet, toBeDeletedCachedKey); err != nil { - metrics.SendErrorLogAndMetric(util.NSID, "[DeleteNamespace] Error: failed to delete namespace %s from ipset list %s with err: %v", cachedNsKey, util.KubeAllNamespacesFlag, err) + metrics.SendErrorLogAndMetric(util.NSID, "[DeleteNamespace] Error: failed to delete namespace %s from ipset list %s with err: %v", cachedNsKey, util.KubeAllNamespacesFlagV2, err) return fmt.Errorf("failed to remove from list during clean deleted namespace %w", err) } diff --git a/npm/pkg/controlplane/controllers/v2/networkPolicyController.go b/npm/pkg/controlplane/controllers/v2/networkPolicyController.go index 84aac9bb9b2..a22508f3205 100644 --- a/npm/pkg/controlplane/controllers/v2/networkPolicyController.go +++ b/npm/pkg/controlplane/controllers/v2/networkPolicyController.go @@ -376,6 +376,7 @@ func isUnsupportedWindowsTranslationErr(err error) bool { // selected pods with no rules while nothing signalled that the policy was never applied. func isUnsupportedTranslationErr(err error) bool { return isUnsupportedWindowsTranslationErr(err) || + (util.IsWindowsDP() && errors.Is(err, translation.ErrUnsupportedIPAddress)) || // NPM Lite only supports CIDR peers; a label-selector peer is out of scope there. errors.Is(err, translation.ErrUnsupportedNonCIDR) } diff --git a/npm/pkg/controlplane/controllers/v2/networkPolicyController_test.go b/npm/pkg/controlplane/controllers/v2/networkPolicyController_test.go index 29aa44f8cbd..473ea1852d6 100644 --- a/npm/pkg/controlplane/controllers/v2/networkPolicyController_test.go +++ b/npm/pkg/controlplane/controllers/v2/networkPolicyController_test.go @@ -685,8 +685,11 @@ func TestSyncAddAndUpdateNetPolSurfacesTranslationFailure(t *testing.T) { dp.EXPECT().UpdatePolicy(gomock.Any()).Times(0) _, err := f.netPolController.syncAddAndUpdateNetPol(netPolObj) - require.Error(t, err, "a translation failure must be surfaced, not reported as success") - require.ErrorIs(t, err, translation.ErrUnsupportedIPAddress) + if util.IsWindowsDP() { + require.NoError(t, err, "an unsupported Windows address must stay suppressed") + } else { + require.ErrorIs(t, err, translation.ErrUnsupportedIPAddress) + } // The policy must not be recorded as applied, so a later retry still reconciles it. netpolKey, keyErr := cache.MetaNamespaceKeyFunc(netPolObj) @@ -717,3 +720,7 @@ func TestSyncAddAndUpdateNetPolSuppressesUnsupportedFeature(t *testing.T) { _, err := f.netPolController.syncAddAndUpdateNetPol(netPolObj) require.NoError(t, err, "an unsupported-feature limitation must stay suppressed") } + +func TestUnsupportedAddressClassificationIsPlatformSpecific(t *testing.T) { + require.Equal(t, util.IsWindowsDP(), isUnsupportedTranslationErr(translation.ErrUnsupportedIPAddress)) +} diff --git a/npm/pkg/controlplane/controllers/v2/podController.go b/npm/pkg/controlplane/controllers/v2/podController.go index 3a3e193058a..568ac7525b8 100644 --- a/npm/pkg/controlplane/controllers/v2/podController.go +++ b/npm/pkg/controlplane/controllers/v2/podController.go @@ -37,7 +37,7 @@ const ( updateEvent string = "UPDATE" ) -var kubeAllNamespaces = &ipsets.IPSetMetadata{Name: util.KubeAllNamespacesFlag, Type: ipsets.KeyLabelOfNamespace} +var kubeAllNamespaces = &ipsets.IPSetMetadata{Name: util.KubeAllNamespacesFlagV2, Type: ipsets.KeyLabelOfNamespace} type PodController struct { podLister corelisters.PodLister diff --git a/npm/pkg/controlplane/translation/namespace_anchor_test.go b/npm/pkg/controlplane/translation/namespace_anchor_test.go new file mode 100644 index 00000000000..43ae6e15265 --- /dev/null +++ b/npm/pkg/controlplane/translation/namespace_anchor_test.go @@ -0,0 +1,29 @@ +package translation + +import ( + "testing" + + "github.com/Azure/azure-container-networking/npm/pkg/dataplane/policies" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNamespaceAnchorDoesNotAliasLabelSets(t *testing.T) { + for _, requirement := range []metav1.LabelSelectorRequirement{ + {Key: "all-namespaces", Operator: metav1.LabelSelectorOpDoesNotExist}, + {Key: "all-namespaces", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"flagged"}}, + {Key: "all", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"namespaces"}}, + } { + t.Run(requirement.Key+"/"+string(requirement.Operator), func(t *testing.T) { + sets, matches := nameSpaceSelector(policies.SrcMatch, &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{requirement}, + }) + require.Len(t, sets, 2) + require.Len(t, matches, 2) + require.NotEqual(t, sets[0].Metadata.GetPrefixName(), sets[1].Metadata.GetPrefixName()) + require.NotEqual(t, sets[0].Metadata.GetHashedName(), sets[1].Metadata.GetHashedName()) + require.False(t, matches[0].Included) + require.True(t, matches[1].Included) + }) + } +} diff --git a/npm/pkg/controlplane/translation/parseSelector.go b/npm/pkg/controlplane/translation/parseSelector.go index 0e67c1e6a3e..ba0f877b728 100644 --- a/npm/pkg/controlplane/translation/parseSelector.go +++ b/npm/pkg/controlplane/translation/parseSelector.go @@ -220,7 +220,7 @@ func flattenNameSpaceSelector(nsSelector *metav1.LabelSelector) ([]metav1.LabelS } } case (req.Operator == metav1.LabelSelectorOpExists) || (req.Operator == metav1.LabelSelectorOpDoesNotExist): - // since Exists and NotExists do not contain any values, NPM can safely add them to the baseSelector + // Exists and DoesNotExist do not carry values. baseSelector.MatchExpressions = append(baseSelector.MatchExpressions, req) default: // Fail closed: an unknown operator must not silently drop the requirement @@ -372,7 +372,7 @@ func parseNSSelector(selector *metav1.LabelSelector) []labelSelector { // #1. All namespaces case if len(selector.MatchLabels) == 0 && len(selector.MatchExpressions) == 0 { - parsedSelectors.addSelector(true, ipsets.KeyLabelOfNamespace, util.KubeAllNamespacesFlag) + parsedSelectors.addSelector(true, ipsets.KeyLabelOfNamespace, util.KubeAllNamespacesFlagV2) return parsedSelectors.labelSelectors } @@ -410,7 +410,7 @@ func parseNSSelector(selector *metav1.LabelSelector) []labelSelector { // admit non-cluster (e.g. internet) peers. Intersect with the all-namespaces set so // the match stays scoped to namespaces, mirroring allowAllInternal. if !parsedSelectors.hasPositiveSelector() { - parsedSelectors.addSelector(true, ipsets.KeyLabelOfNamespace, util.KubeAllNamespacesFlag) + parsedSelectors.addSelector(true, ipsets.KeyLabelOfNamespace, util.KubeAllNamespacesFlagV2) } return parsedSelectors.labelSelectors diff --git a/npm/pkg/controlplane/translation/translatePolicy.go b/npm/pkg/controlplane/translation/translatePolicy.go index 52e8ba474e8..29b2a2be588 100644 --- a/npm/pkg/controlplane/translation/translatePolicy.go +++ b/npm/pkg/controlplane/translation/translatePolicy.go @@ -233,8 +233,7 @@ func ipBlockIPSet(policyName, ns string, direction policies.Direction, ipBlockSe return nil, ErrUnsupportedExceptCIDR } - // de-duplicated Except if there are redundance elements, in canonical form so they - // compare correctly against the all-addresses split entries below. + // Canonicalize and deduplicate exclusions before comparing with the split entries. deDupExcepts, err := canonicalizeExcepts(ipBlockRule.Except) if err != nil { return nil, err @@ -297,7 +296,7 @@ func ipBlockRule(policyName, ns string, direction policies.Direction, matchType // form, but IsIPV4 refuses a /0 that is not spelled "0.0.0.0". Rejecting here aborts the // translation of the whole policy, so neither the peer rule nor the default drop the policy // implies is installed and the selected pods are left with no rules at all. This is the - // ipset path, which is Linux only; the Windows direct-rule path is unchanged. + // shared ipset path; the Windows NPM Lite direct-rule path is unchanged. if _, ok := util.NormalizeCIDR(ipBlockRule.CIDR); !ok { return nil, policies.SetInfo{}, ErrUnsupportedIPAddress } @@ -371,8 +370,8 @@ func nameSpaceSelector(matchType policies.MatchType, selector *metav1.LabelSelec // allowAllInternal returns translatedIPSet and SetInfo in case of allowing all internal traffic excluding external. func allowAllInternal(matchType policies.MatchType) (*ipsets.TranslatedIPSet, policies.SetInfo) { - allowAllIPSets := ipsets.NewTranslatedIPSet(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace) - setInfo := policies.NewSetInfo(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace, included, matchType) + allowAllIPSets := ipsets.NewTranslatedIPSet(util.KubeAllNamespacesFlagV2, ipsets.KeyLabelOfNamespace) + setInfo := policies.NewSetInfo(util.KubeAllNamespacesFlagV2, ipsets.KeyLabelOfNamespace, included, matchType) return allowAllIPSets, setInfo } diff --git a/npm/pkg/controlplane/translation/translatePolicy_test.go b/npm/pkg/controlplane/translation/translatePolicy_test.go index 209b1cf62ba..b3f7cb045ab 100644 --- a/npm/pkg/controlplane/translation/translatePolicy_test.go +++ b/npm/pkg/controlplane/translation/translatePolicy_test.go @@ -1144,10 +1144,10 @@ func TestNameSpaceSelector(t *testing.T) { MatchLabels: map[string]string{}, }, nsSelectorIPSets: []*ipsets.TranslatedIPSet{ - ipsets.NewTranslatedIPSet(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace), + ipsets.NewTranslatedIPSet(util.KubeAllNamespacesFlagV2, ipsets.KeyLabelOfNamespace), }, nsSelectorList: []policies.SetInfo{ - policies.NewSetInfo(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace, included, matchType), + policies.NewSetInfo(util.KubeAllNamespacesFlagV2, ipsets.KeyLabelOfNamespace, included, matchType), }, }, { @@ -1327,7 +1327,7 @@ func TestNameSpaceSelectorMultiValueNotIn(t *testing.T) { expected := []policies.SetInfo{ // The all-namespaces set keeps the negation-only match scoped to cluster namespaces. - policies.NewSetInfo(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace, included, matchType), + policies.NewSetInfo(util.KubeAllNamespacesFlagV2, ipsets.KeyLabelOfNamespace, included, matchType), policies.NewSetInfo("tenant:x", ipsets.KeyValueLabelOfNamespace, nonIncluded, matchType), policies.NewSetInfo("tenant:y", ipsets.KeyValueLabelOfNamespace, nonIncluded, matchType), } @@ -1383,7 +1383,7 @@ func TestNameSpaceSelectorNegationOnlyIsScopedToNamespaces(t *testing.T) { }, }, expected: []policies.SetInfo{ - policies.NewSetInfo(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace, included, matchType), + policies.NewSetInfo(util.KubeAllNamespacesFlagV2, ipsets.KeyLabelOfNamespace, included, matchType), policies.NewSetInfo("tenant:x", ipsets.KeyValueLabelOfNamespace, nonIncluded, matchType), }, }, @@ -1395,7 +1395,7 @@ func TestNameSpaceSelectorNegationOnlyIsScopedToNamespaces(t *testing.T) { }, }, expected: []policies.SetInfo{ - policies.NewSetInfo(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace, included, matchType), + policies.NewSetInfo(util.KubeAllNamespacesFlagV2, ipsets.KeyLabelOfNamespace, included, matchType), policies.NewSetInfo(tenantLabelKey, ipsets.KeyLabelOfNamespace, nonIncluded, matchType), }, }, @@ -1408,7 +1408,7 @@ func TestNameSpaceSelectorNegationOnlyIsScopedToNamespaces(t *testing.T) { }, }, expected: []policies.SetInfo{ - policies.NewSetInfo(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace, included, matchType), + policies.NewSetInfo(util.KubeAllNamespacesFlagV2, ipsets.KeyLabelOfNamespace, included, matchType), policies.NewSetInfo("tenant:x", ipsets.KeyValueLabelOfNamespace, nonIncluded, matchType), policies.NewSetInfo(teamLabelKey, ipsets.KeyLabelOfNamespace, nonIncluded, matchType), }, @@ -1421,7 +1421,7 @@ func TestNameSpaceSelectorNegationOnlyIsScopedToNamespaces(t *testing.T) { require.ElementsMatch(t, tt.expected, nsSelectorList) // The all-namespaces set must also be translated so it exists in the dataplane. require.Contains(t, nsSelectorIPSets, - ipsets.NewTranslatedIPSet(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace)) + ipsets.NewTranslatedIPSet(util.KubeAllNamespacesFlagV2, ipsets.KeyLabelOfNamespace)) }) } } @@ -1473,7 +1473,7 @@ func TestNameSpaceSelectorWithPositiveMatchIsUnchanged(t *testing.T) { name: "empty selector still resolves to all namespaces once", selector: &metav1.LabelSelector{}, expected: []policies.SetInfo{ - policies.NewSetInfo(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace, included, matchType), + policies.NewSetInfo(util.KubeAllNamespacesFlagV2, ipsets.KeyLabelOfNamespace, included, matchType), }, }, } @@ -1534,13 +1534,13 @@ func TestTranslatePolicyNegationOnlyNamespaceSelector(t *testing.T) { peers := tt.peerList(theAllow) require.ElementsMatch(t, []policies.SetInfo{ - policies.NewSetInfo(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace, included, tt.matchType), + policies.NewSetInfo(util.KubeAllNamespacesFlagV2, ipsets.KeyLabelOfNamespace, included, tt.matchType), policies.NewSetInfo("tenant:x", ipsets.KeyValueLabelOfNamespace, nonIncluded, tt.matchType), }, peers, "a negation-only namespaceSelector must be intersected with the all-namespaces set") var sawAllNamespaces bool for _, si := range peers { - if si.Included && si.IPSet.Name == util.KubeAllNamespacesFlag { + if si.Included && si.IPSet.Name == util.KubeAllNamespacesFlagV2 { sawAllNamespaces = true } } @@ -1656,7 +1656,7 @@ func TestTranslatePolicyMultiValueNotInConjunction(t *testing.T) { var positive []string for _, si := range allowPeers { if si.Included { - require.Equal(t, util.KubeAllNamespacesFlag, si.IPSet.Name, + require.Equal(t, util.KubeAllNamespacesFlagV2, si.IPSet.Name, "the only positive set may be the all-namespaces set") require.Equal(t, ipsets.KeyLabelOfNamespace, si.IPSet.Type) positive = append(positive, si.IPSet.Name) @@ -1668,7 +1668,7 @@ func TestTranslatePolicyMultiValueNotInConjunction(t *testing.T) { } require.ElementsMatch(t, []string{"tenant:attacker", "tenant:quarantine"}, negated, "the single allow ACL must negate every excluded value") - require.Equal(t, []string{util.KubeAllNamespacesFlag}, positive, + require.Equal(t, []string{util.KubeAllNamespacesFlagV2}, positive, "the negation-only match must be intersected with the all-namespaces set") // The default drop must be same-direction and unconditional (no peer match), @@ -1697,8 +1697,8 @@ func TestAllowAllInternal(t *testing.T) { { name: "Allow all traffic from all namespaces in ingress", matchType: matchType, - nsSelectorIPSets: ipsets.NewTranslatedIPSet(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace), - nsSelectorList: policies.NewSetInfo(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace, included, matchType), + nsSelectorIPSets: ipsets.NewTranslatedIPSet(util.KubeAllNamespacesFlagV2, ipsets.KeyLabelOfNamespace), + nsSelectorList: policies.NewSetInfo(util.KubeAllNamespacesFlagV2, ipsets.KeyLabelOfNamespace, included, matchType), }, } @@ -4059,7 +4059,7 @@ func TestTranslatePolicyNegationOnlyOperators(t *testing.T) { require.NotNil(t, theAllow) peers := dir.peerList(theAllow) - anchor := policies.NewSetInfo(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace, included, dir.matchType) + anchor := policies.NewSetInfo(util.KubeAllNamespacesFlagV2, ipsets.KeyLabelOfNamespace, included, dir.matchType) require.Contains(t, peers, anchor, "a negation-only namespaceSelector must carry the all-namespaces anchor, "+ "otherwise the negation alone also matches addresses that are not pods") @@ -4076,7 +4076,7 @@ func TestTranslatePolicyNegationOnlyOperators(t *testing.T) { positives = append(positives, si.IPSet.Name) } } - require.Equal(t, []string{util.KubeAllNamespacesFlag}, positives) + require.Equal(t, []string{util.KubeAllNamespacesFlagV2}, positives) }) } } diff --git a/npm/pkg/dataplane/policies/policymanager_linux_test.go b/npm/pkg/dataplane/policies/policymanager_linux_test.go index 06bd69ce90b..2a95f24d5e0 100644 --- a/npm/pkg/dataplane/policies/policymanager_linux_test.go +++ b/npm/pkg/dataplane/policies/policymanager_linux_test.go @@ -525,7 +525,7 @@ func TestUpdatingStaleChains(t *testing.T) { // domain, and this test pins that it renders as a positive `--match-set` in the same rule as the // negation, in both directions. func TestNegationOnlyPeerRendersAnchor(t *testing.T) { - anchor := ipsets.NewIPSetMetadata(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace) + anchor := ipsets.NewIPSetMetadata(util.KubeAllNamespacesFlagV2, ipsets.KeyLabelOfNamespace) excluded := ipsets.NewIPSetMetadata("blocked", ipsets.KeyLabelOfNamespace) tests := []struct { @@ -545,7 +545,7 @@ func TestNegationOnlyPeerRendersAnchor(t *testing.T) { Direction: tt.direction, } peers := []SetInfo{ - NewSetInfo(util.KubeAllNamespacesFlag, ipsets.KeyLabelOfNamespace, true, tt.matchType), + NewSetInfo(util.KubeAllNamespacesFlagV2, ipsets.KeyLabelOfNamespace, true, tt.matchType), NewSetInfo("blocked", ipsets.KeyLabelOfNamespace, false, tt.matchType), } if tt.matchType == SrcMatch { diff --git a/npm/util/const.go b/npm/util/const.go index e323d618b0e..fb926ab5422 100644 --- a/npm/util/const.go +++ b/npm/util/const.go @@ -6,10 +6,13 @@ import "k8s.io/klog" // kubernetes related constants. const ( - KubeSystemFlag string = "kube-system" - KubePodTemplateHashFlag string = "pod-template-hash" - KubeAllPodsFlag string = "all-pod" - KubeAllNamespacesFlag string = "all-namespaces" + KubeSystemFlag string = "kube-system" + KubePodTemplateHashFlag string = "pod-template-hash" + KubeAllPodsFlag string = "all-pod" + KubeAllNamespacesFlag string = "all-namespaces" + // A leading colon cannot occur in a label key or a key:value label identity. + // Keep the v1 name unchanged and separate v2 aggregate membership from labels. + KubeAllNamespacesFlagV2 string = ":all-namespaces" KubeAppFlag string = "k8s-app" KubeProxyFlag string = "kube-proxy" KubePodStatusFailedFlag string = "Failed" From 0344b139b8cbd15ba4a7db7c18209f65adf11ade Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Mon, 14 Sep 2026 19:37:05 +0000 Subject: [PATCH 20/33] fix: [NPM] retain CIDR error details during translation Distinguish malformed CIDRs from unsupported IP families while preserving accepted spellings and the existing translation error classification. Normalize the parent CIDR only in the set builder and make namespace-anchor assertions independent of match order. Add error-propagation and compatibility coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 175c523d-ad1c-4e31-986d-7c9940c6c18c --- .../v2/networkPolicyController_test.go | 1 + .../translation/cidr_errors_test.go | 64 +++++++++++++++++++ .../translation/namespace_anchor_test.go | 14 +++- .../translation/translatePolicy.go | 23 ++----- npm/util/util.go | 21 ++++-- npm/util/util_test.go | 53 +++++++++++++-- 6 files changed, 147 insertions(+), 29 deletions(-) create mode 100644 npm/pkg/controlplane/translation/cidr_errors_test.go diff --git a/npm/pkg/controlplane/controllers/v2/networkPolicyController_test.go b/npm/pkg/controlplane/controllers/v2/networkPolicyController_test.go index 473ea1852d6..aa42d5c3757 100644 --- a/npm/pkg/controlplane/controllers/v2/networkPolicyController_test.go +++ b/npm/pkg/controlplane/controllers/v2/networkPolicyController_test.go @@ -689,6 +689,7 @@ func TestSyncAddAndUpdateNetPolSurfacesTranslationFailure(t *testing.T) { require.NoError(t, err, "an unsupported Windows address must stay suppressed") } else { require.ErrorIs(t, err, translation.ErrUnsupportedIPAddress) + require.ErrorIs(t, err, util.ErrUnsupportedIPFamily) } // The policy must not be recorded as applied, so a later retry still reconciles it. diff --git a/npm/pkg/controlplane/translation/cidr_errors_test.go b/npm/pkg/controlplane/translation/cidr_errors_test.go new file mode 100644 index 00000000000..4ce6a6655ac --- /dev/null +++ b/npm/pkg/controlplane/translation/cidr_errors_test.go @@ -0,0 +1,64 @@ +package translation + +import ( + "testing" + + "github.com/Azure/azure-container-networking/npm/pkg/dataplane/policies" + "github.com/Azure/azure-container-networking/npm/util" + "github.com/stretchr/testify/require" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestIPBlockNormalizationErrorCauses(t *testing.T) { + for _, test := range []struct { + name string + block networkingv1.IPBlock + cause error + }{ + {"invalid prefix", networkingv1.IPBlock{CIDR: "192.0.2.0/33"}, util.ErrInvalidCIDR}, + {"bare address", networkingv1.IPBlock{CIDR: "192.0.2.1"}, util.ErrInvalidCIDR}, + {"IPv6 prefix", networkingv1.IPBlock{CIDR: "2001:db8:2::/48"}, util.ErrUnsupportedIPFamily}, + {"mapped IPv6 prefix", networkingv1.IPBlock{CIDR: "::ffff:192.0.2.0/120"}, util.ErrUnsupportedIPFamily}, + {"invalid exclusion", networkingv1.IPBlock{CIDR: enclosingCIDR, Except: []string{"invalid"}}, util.ErrInvalidCIDR}, + {"IPv6 exclusion", networkingv1.IPBlock{CIDR: enclosingCIDR, Except: []string{"2001:db8:2::/48"}}, util.ErrUnsupportedIPFamily}, + } { + t.Run(test.name, func(t *testing.T) { + before := test.block.DeepCopy() + set, info, err := ipBlockRule("normalization", defaultNS, policies.Ingress, policies.SrcMatch, 0, 0, &test.block) + require.Nil(t, set) + require.Equal(t, policies.SetInfo{}, info) + require.Equal(t, before, &test.block) + + if util.IsWindowsDP() && len(test.block.Except) > 0 { + require.ErrorIs(t, err, ErrUnsupportedExceptCIDR) + } else { + require.ErrorIs(t, err, ErrUnsupportedIPAddress) + require.ErrorIs(t, err, test.cause) + } + + for _, direction := range []networkingv1.PolicyType{networkingv1.PolicyTypeIngress, networkingv1.PolicyTypeEgress} { + policy := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "normalization", Namespace: defaultNS}, + Spec: networkingv1.NetworkPolicySpec{ + PolicyTypes: []networkingv1.PolicyType{direction}, + }, + } + peers := []networkingv1.NetworkPolicyPeer{{IPBlock: &test.block}} + if direction == networkingv1.PolicyTypeIngress { + policy.Spec.Ingress = []networkingv1.NetworkPolicyIngressRule{{From: peers}} + } else { + policy.Spec.Egress = []networkingv1.NetworkPolicyEgressRule{{To: peers}} + } + translated, policyErr := TranslatePolicy(policy, false) + require.Nil(t, translated) + if util.IsWindowsDP() && len(test.block.Except) > 0 { + require.ErrorIs(t, policyErr, ErrUnsupportedExceptCIDR) + } else { + require.ErrorIs(t, policyErr, ErrUnsupportedIPAddress) + require.ErrorIs(t, policyErr, test.cause) + } + } + }) + } +} diff --git a/npm/pkg/controlplane/translation/namespace_anchor_test.go b/npm/pkg/controlplane/translation/namespace_anchor_test.go index 43ae6e15265..09c01befa1d 100644 --- a/npm/pkg/controlplane/translation/namespace_anchor_test.go +++ b/npm/pkg/controlplane/translation/namespace_anchor_test.go @@ -3,7 +3,9 @@ package translation import ( "testing" + "github.com/Azure/azure-container-networking/npm/pkg/dataplane/ipsets" "github.com/Azure/azure-container-networking/npm/pkg/dataplane/policies" + "github.com/Azure/azure-container-networking/npm/util" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -22,8 +24,16 @@ func TestNamespaceAnchorDoesNotAliasLabelSets(t *testing.T) { require.Len(t, matches, 2) require.NotEqual(t, sets[0].Metadata.GetPrefixName(), sets[1].Metadata.GetPrefixName()) require.NotEqual(t, sets[0].Metadata.GetHashedName(), sets[1].Metadata.GetHashedName()) - require.False(t, matches[0].Included) - require.True(t, matches[1].Included) + name := requirement.Key + setType := ipsets.KeyLabelOfNamespace + if requirement.Operator == metav1.LabelSelectorOpNotIn { + name = util.GetIpSetFromLabelKV(requirement.Key, requirement.Values[0]) + setType = ipsets.KeyValueLabelOfNamespace + } + require.ElementsMatch(t, []policies.SetInfo{ + policies.NewSetInfo(name, setType, false, policies.SrcMatch), + policies.NewSetInfo(util.KubeAllNamespacesFlagV2, ipsets.KeyLabelOfNamespace, true, policies.SrcMatch), + }, matches) }) } } diff --git a/npm/pkg/controlplane/translation/translatePolicy.go b/npm/pkg/controlplane/translation/translatePolicy.go index 29b2a2be588..e54da48363a 100644 --- a/npm/pkg/controlplane/translation/translatePolicy.go +++ b/npm/pkg/controlplane/translation/translatePolicy.go @@ -200,9 +200,9 @@ func canonicalizeExcepts(exceptInIPBlock []string) ([]string, error) { canonicalExcepts := []string{} exceptsSet := make(map[string]struct{}) for _, except := range exceptInIPBlock { - canonical, ok := util.NormalizeCIDR(except) - if !ok { - return nil, fmt.Errorf("except %q: %w", except, ErrUnsupportedIPAddress) + canonical, err := util.NormalizeCIDR(except) + if err != nil { + return nil, fmt.Errorf("except %q: %w: %w", except, ErrUnsupportedIPAddress, err) } if _, exist := exceptsSet[canonical]; !exist { canonicalExcepts = append(canonicalExcepts, canonical) @@ -222,9 +222,9 @@ func ipBlockIPSet(policyName, ns string, direction policies.Direction, ipBlockSe // with host bits set (e.g. "10.0.0.0/0") denotes the same addresses as its canonical form // but does not compare equal to it, so without this the all-addresses block below would // not be recognized and the literal would be rejected by ipset. - cidr, ok := util.NormalizeCIDR(ipBlockRule.CIDR) - if !ok { - return nil, ErrUnsupportedIPAddress + cidr, err := util.NormalizeCIDR(ipBlockRule.CIDR) + if err != nil { + return nil, fmt.Errorf("ipBlock %q: %w: %w", ipBlockRule.CIDR, ErrUnsupportedIPAddress, err) } // The Windows datapath refuses an except before any of it is canonicalized, exactly as @@ -291,16 +291,7 @@ func ipBlockRule(policyName, ns string, direction policies.Direction, matchType return nil, policies.SetInfo{}, nil } - // Validate the canonical form rather than the literal the user wrote. A block whose host - // bits are set, such as "10.0.0.0/0", denotes exactly the same addresses as its canonical - // form, but IsIPV4 refuses a /0 that is not spelled "0.0.0.0". Rejecting here aborts the - // translation of the whole policy, so neither the peer rule nor the default drop the policy - // implies is installed and the selected pods are left with no rules at all. This is the - // shared ipset path; the Windows NPM Lite direct-rule path is unchanged. - if _, ok := util.NormalizeCIDR(ipBlockRule.CIDR); !ok { - return nil, policies.SetInfo{}, ErrUnsupportedIPAddress - } - + // The set builder validates and normalizes the CIDR once, before creating any members. ipBlockIPSet, err := ipBlockIPSet(policyName, ns, direction, ipBlockSetIndex, ipBlockPeerIndex, ipBlockRule) if err != nil { return nil, policies.SetInfo{}, err diff --git a/npm/util/util.go b/npm/util/util.go index bf208ee5b19..7fd7d091659 100644 --- a/npm/util/util.go +++ b/npm/util/util.go @@ -34,6 +34,13 @@ const ( var ErrEmptyNodeIP = errors.New("error: node IP is empty") +var ( + // ErrInvalidCIDR identifies a CIDR that cannot be parsed. + ErrInvalidCIDR = errors.New("util: invalid CIDR") + // ErrUnsupportedIPFamily identifies a valid CIDR outside the supported IPv4 family. + ErrUnsupportedIPFamily = errors.New("util: unsupported IP family") +) + // regex to get minor version var re = regexp.MustCompile("[0-9]+") @@ -365,15 +372,19 @@ func SliceToString(list []string) string { // NormalizeCIDR returns the canonical form of an IPv4 CIDR, i.e. the block with its host // bits cleared, so "10.0.0.0/0" becomes "0.0.0.0/0" and "10.1.2.3/24" becomes "10.1.2.0/24". -// It reports false when s is not an IPv4 CIDR. Callers must normalize before comparing a +// It distinguishes invalid CIDRs from unsupported IP families. Callers must normalize before comparing a // CIDR against a well-known block or handing it to the kernel, because a non-canonical // spelling denotes the same block but does not compare equal and is not accepted by ipset. -func NormalizeCIDR(s string) (string, bool) { +func NormalizeCIDR(s string) (string, error) { + // Retain accepted spellings, including zero-padded prefix lengths. _, network, err := net.ParseCIDR(s) - if err != nil || network.IP.To4() == nil || len(network.Mask) != net.IPv4len { - return "", false + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidCIDR, err) + } + if network.IP.To4() == nil || len(network.Mask) != net.IPv4len { + return "", ErrUnsupportedIPFamily } - return network.String(), true + return network.String(), nil } // IsIPV4 returns true when ip is an IPv4 address or an IPv4 CIDR block. diff --git a/npm/util/util_test.go b/npm/util/util_test.go index f16035b27be..acb896061be 100644 --- a/npm/util/util_test.go +++ b/npm/util/util_test.go @@ -1,6 +1,7 @@ package util import ( + "net" "reflect" "strings" "testing" @@ -568,18 +569,58 @@ func TestNormalizeCIDR(t *testing.T) { "10.0.0.0/1": "0.0.0.0/1", "200.0.0.0/1": "128.0.0.0/1", "10.1.2.3/24": canonicalNet24, + "10.1.2.3/024": canonicalNet24, canonicalNet24: canonicalNet24, singleHostCIDR: singleHostCIDR, } for in, want := range canonical { - got, ok := NormalizeCIDR(in) - require.True(t, ok, "NormalizeCIDR(%q) must succeed", in) + got, err := NormalizeCIDR(in) + require.NoError(t, err, "NormalizeCIDR(%q) must succeed", in) require.Equal(t, want, got, "NormalizeCIDR(%q)", in) } - for _, in := range []string{"", "10.0.0.1", "not-a-cidr", "10.0.0.0/33", "2001:db8::/32", "::/0"} { - got, ok := NormalizeCIDR(in) - require.False(t, ok, "NormalizeCIDR(%q) must fail", in) - require.Empty(t, got) + for _, test := range []struct { + cidr string + want error + }{ + {"", ErrInvalidCIDR}, + {"10.0.0.1", ErrInvalidCIDR}, + {"not-a-cidr", ErrInvalidCIDR}, + {"10.0.0.0/33", ErrInvalidCIDR}, + {"2001:db8::/32", ErrUnsupportedIPFamily}, + {"::/0", ErrUnsupportedIPFamily}, + {"::ffff:192.0.2.1/128", ErrUnsupportedIPFamily}, + } { + t.Run(test.cidr, func(t *testing.T) { + got, err := NormalizeCIDR(test.cidr) + require.ErrorIs(t, err, test.want) + require.Empty(t, got) + }) } } + +func FuzzNormalizeCIDRCompatibility(f *testing.F) { + for _, input := range []string{ + allIPv4CIDR, singleHostCIDR, "192.168.7.19/24", "192.168.7.19/0", + "2001:db8:1::/48", "::ffff:192.0.2.1/128", "0::/00", "broken", "", + } { + f.Add(input) + } + f.Fuzz(func(t *testing.T, input string) { + // Preserve the accepted values and canonical output of the previous classifier. + _, network, parseErr := net.ParseCIDR(input) + got, err := NormalizeCIDR(input) + switch { + case parseErr != nil: + require.ErrorIs(t, err, ErrInvalidCIDR) + case network.IP.To4() == nil || len(network.Mask) != net.IPv4len: + require.ErrorIs(t, err, ErrUnsupportedIPFamily) + default: + require.NoError(t, err) + require.Equal(t, network.String(), got) + } + if err != nil { + require.Empty(t, got) + } + }) +} From 4ab92bb89a9cb21c4f01ebefe8f3491071041d10 Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Mon, 14 Sep 2026 22:16:13 +0000 Subject: [PATCH 21/33] fix: [NPM] classify policy errors and namespace debug matches Report deterministic full-NPM translation failures without repeated retries, preserve dataplane retries and Lite behavior, and distinguish invalid CIDRs from Windows address-family limitations. Preserve parent-CIDR precedence and evaluate namespace-anchor conditions without changing legacy tuple matching. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 175c523d-ad1c-4e31-986d-7c9940c6c18c --- .../controlplane/controllers/common/cache.go | 10 + .../controllers/v2/networkPolicyController.go | 39 ++-- .../v2/networkPolicyController_retry_test.go | 211 ++++++++++++++++++ .../v2/networkPolicyController_test.go | 21 +- .../translation/cidr_errors_test.go | 32 ++- .../translation/translatePolicy.go | 4 +- .../dataplane/debug/namespace_anchor_test.go | 117 ++++++++++ npm/pkg/dataplane/debug/trafficanalyzer.go | 64 +++++- 8 files changed, 464 insertions(+), 34 deletions(-) create mode 100644 npm/pkg/controlplane/controllers/v2/networkPolicyController_retry_test.go create mode 100644 npm/pkg/dataplane/debug/namespace_anchor_test.go diff --git a/npm/pkg/controlplane/controllers/common/cache.go b/npm/pkg/controlplane/controllers/common/cache.go index ad5cba85bd6..c03f9ff9afe 100644 --- a/npm/pkg/controlplane/controllers/common/cache.go +++ b/npm/pkg/controlplane/controllers/common/cache.go @@ -46,6 +46,7 @@ const ( type GenericCache interface { GetPod(*Input) (*NpmPod, error) GetNamespaceLabel(namespace string, key string) string + GetNamespaceLabels(namespace string) (map[string]string, bool) GetListMap() map[string]string GetSetMap() map[string]string } @@ -86,6 +87,15 @@ func (c *Cache) GetNamespaceLabel(namespace, labelkey string) string { return "" } +// GetNamespaceLabels distinguishes a missing namespace from one with no labels. +func (c *Cache) GetNamespaceLabels(namespace string) (map[string]string, bool) { + ns, ok := c.NsMap[namespace] + if !ok || ns == nil { + return nil, false + } + return ns.LabelsMap, true +} + func (c *Cache) GetSetMap() map[string]string { return c.SetMap } diff --git a/npm/pkg/controlplane/controllers/v2/networkPolicyController.go b/npm/pkg/controlplane/controllers/v2/networkPolicyController.go index a22508f3205..09ab776173f 100644 --- a/npm/pkg/controlplane/controllers/v2/networkPolicyController.go +++ b/npm/pkg/controlplane/controllers/v2/networkPolicyController.go @@ -186,6 +186,10 @@ func (c *NetworkPolicyController) processNextWorkItem() bool { // Run the syncNetPol, passing it the namespace/name string of the // network policy resource to be synced. if err := c.syncNetPol(key); err != nil { + if errors.Is(err, errNetPolTranslationFailure) { + c.workqueue.Forget(obj) + return fmt.Errorf("error syncing '%s': %w; waiting for a policy change", key, err) + } // Put the item back on the workqueue to handle any transient errors. c.workqueue.AddRateLimited(key) return fmt.Errorf("error syncing '%s': %w, requeuing", key, err) @@ -291,7 +295,7 @@ func (c *NetworkPolicyController) syncAddAndUpdateNetPol(netPolObj *networkingv1 // install translated rules into kernel npmNetPolObj, err := translation.TranslatePolicy(netPolObj, c.npmLiteToggle) if err != nil { - if isUnsupportedTranslationErr(err) { + if isUnsupportedTranslationErr(err, c.npmLiteToggle) { klog.Warningf("NetworkPolicy %s in namespace %s is not translated because it uses a feature this datapath does not support: %s", netPolObj.ObjectMeta.Name, netPolObj.ObjectMeta.Namespace, err.Error()) @@ -300,17 +304,14 @@ func (c *NetworkPolicyController) syncAddAndUpdateNetPol(netPolObj *networkingv1 return metrics.NoOp, nil } - // Do not report success here. Reporting success left the policy's selected pods with - // no rules at all - not even the default drop the policy implies - while the policy - // object appeared to be applied and nothing signalled the failure. Return the error so - // it is surfaced and the key is requeued (rate limited) instead. - // - // The error is deliberately not logged or counted here: processNextWorkItem already - // runs the returned error through utilruntime.HandleError and SendErrorLogAndMetric, - // so recording it here as well would emit the same failure three times. The wrapped - // message names the policy so that single record stays specific. - // - // The exec time isn't relevant here, so consider a no-op. + // Translation depends only on the policy and fixed controller mode. Full NPM + // reports deterministic failures without retrying or caching an unapplied spec; + // the informer queues changed resource versions. Dataplane errors below remain + // retryable, and Lite keeps its existing error handling. The worker reports errors. + if !c.npmLiteToggle { + return metrics.NoOp, fmt.Errorf("%w %s/%s: %w", + errNetPolTranslationFailure, netPolObj.Namespace, netPolObj.Name, err) + } return metrics.NoOp, fmt.Errorf("translating network policy %s/%s: %w", netPolObj.Namespace, netPolObj.Name, err) } @@ -371,12 +372,16 @@ func isUnsupportedWindowsTranslationErr(err error) bool { // isUnsupportedTranslationErr reports whether err is a deliberate limitation of the datapath // or mode NPM is running in, rather than a policy NPM failed to translate. Those limitations -// cannot resolve on retry, so they stay suppressed with a warning. Every other translation -// failure is surfaced and requeued, because reporting success would leave the policy's -// selected pods with no rules while nothing signalled that the policy was never applied. -func isUnsupportedTranslationErr(err error) bool { +// stay suppressed with a warning; other failures must be reported without recording success. +func isUnsupportedTranslationErr(err error, npmLiteToggle bool) bool { + if errors.Is(err, util.ErrInvalidCIDR) { + return false + } + // Full NPM supplies a typed cause; only Lite retains unclassified address errors. + unsupportedAddress := errors.Is(err, util.ErrUnsupportedIPFamily) || + (npmLiteToggle && errors.Is(err, translation.ErrUnsupportedIPAddress)) return isUnsupportedWindowsTranslationErr(err) || - (util.IsWindowsDP() && errors.Is(err, translation.ErrUnsupportedIPAddress)) || + (util.IsWindowsDP() && unsupportedAddress) || // NPM Lite only supports CIDR peers; a label-selector peer is out of scope there. errors.Is(err, translation.ErrUnsupportedNonCIDR) } diff --git a/npm/pkg/controlplane/controllers/v2/networkPolicyController_retry_test.go b/npm/pkg/controlplane/controllers/v2/networkPolicyController_retry_test.go new file mode 100644 index 00000000000..5f5871d7a49 --- /dev/null +++ b/npm/pkg/controlplane/controllers/v2/networkPolicyController_retry_test.go @@ -0,0 +1,211 @@ +package controllers + +import ( + "fmt" + "testing" + + "github.com/Azure/azure-container-networking/npm/pkg/controlplane/translation" + dpmocks "github.com/Azure/azure-container-networking/npm/pkg/dataplane/mocks" + "github.com/Azure/azure-container-networking/npm/util" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/util/workqueue" +) + +func newNetPolQueueFixture(t *testing.T, policy *networkingv1.NetworkPolicy, dp *dpmocks.MockGenericDataplane, npmLite bool) *netPolFixture { + t.Helper() + f := newNetPolFixture(t) + f.netPolLister = append(f.netPolLister, policy) + f.kubeobjects = append(f.kubeobjects, policy) + f.newNetPolController(nil, dp, npmLite) + f.netPolController.workqueue.ShutDown() + // Zero-delay retries make incorrect requeueing observable without sleeps. + f.netPolController.workqueue = workqueue.NewTypedRateLimitingQueue[any](workqueue.NewTypedItemFastSlowRateLimiter[any](0, 0, 1)) + t.Cleanup(f.netPolController.workqueue.ShutDown) + return f +} + +func TestFullNPMTranslationFailureWaitsForPolicyChange(t *testing.T) { + oversized := netPolWithCIDR("192.0.2.0/24") + selector := &metav1.LabelSelector{} + for i := 0; i < 19; i++ { + selector.MatchExpressions = append(selector.MatchExpressions, metav1.LabelSelectorRequirement{ + Key: fmt.Sprintf("key%d", i), Operator: metav1.LabelSelectorOpIn, Values: []string{"a", "b"}, + }) + } + oversized.Spec.Ingress[0].From = []networkingv1.NetworkPolicyPeer{{NamespaceSelector: selector}} + invalidWithExcept := netPolWithCIDR("192.0.2.0/33") + invalidWithExcept.Spec.Ingress[0].From[0].IPBlock.Except = []string{"192.0.2.1/32"} + unknownOperator := netPolWithCIDR("192.0.2.0/24") + unknownOperator.Spec.Ingress[0].From = []networkingv1.NetworkPolicyPeer{{ + NamespaceSelector: &metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{{ + Key: "tenant", Operator: "Unknown", + }}}, + }} + + tests := []struct { + name string + policy *networkingv1.NetworkPolicy + cause error + }{ + {"malformed CIDR", netPolWithCIDR("192.0.2.0/33"), util.ErrInvalidCIDR}, + {"malformed CIDR with Except", invalidWithExcept, util.ErrInvalidCIDR}, + {"selector expansion", oversized, translation.ErrTooManyFlattenedSelectors}, + {"unsupported operator", unknownOperator, translation.ErrUnsupportedMatchExpressionOperator}, + } + if !util.IsWindowsDP() { + tests = append(tests, struct { + name string + policy *networkingv1.NetworkPolicy + cause error + }{"unsupported family", netPolWithCIDR("2001:db8::/32"), util.ErrUnsupportedIPFamily}) + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + test.policy.ResourceVersion = "1" + ctrl := gomock.NewController(t) + dp := dpmocks.NewMockGenericDataplane(ctrl) + f := newNetPolQueueFixture(t, test.policy, dp, false) + c := f.netPolController + key := getKey(test.policy, t) + + _, err := c.syncAddAndUpdateNetPol(test.policy) + require.ErrorIs(t, err, test.cause) + require.ErrorIs(t, err, errNetPolTranslationFailure) + require.ErrorContains(t, err, key) + require.Empty(t, c.rawNpSpecMap) + + c.addNetworkPolicy(test.policy) + require.Equal(t, 1, c.workqueue.Len()) + require.True(t, c.processNextWorkItem()) + require.Zero(t, c.workqueue.NumRequeues(key)) + require.Zero(t, c.workqueue.Len()) + require.Empty(t, c.rawNpSpecMap) + + c.updateNetworkPolicy(test.policy, test.policy.DeepCopy()) + require.Zero(t, c.workqueue.Len(), "an informer resync must not repeat the rejection") + + corrected := test.policy.DeepCopy() + corrected.ResourceVersion = "2" + corrected.Spec = netPolWithCIDR("10.0.0.0/0").Spec + require.NoError(t, f.kubeInformer.Networking().V1().NetworkPolicies().Informer().GetIndexer().Update(corrected)) + dp.EXPECT().UpdatePolicy(gomock.Any()).Return(nil).Times(1) + c.updateNetworkPolicy(test.policy, corrected) + require.Equal(t, 1, c.workqueue.Len()) + require.True(t, c.processNextWorkItem()) + require.Equal(t, &corrected.Spec, c.rawNpSpecMap[key]) + require.Zero(t, c.workqueue.NumRequeues(key)) + require.Zero(t, c.workqueue.Len()) + }) + } +} + +func TestFullNPMDataplaneFailureStillRetries(t *testing.T) { + policy := netPolWithCIDR("10.0.0.0/0") + ctrl := gomock.NewController(t) + dp := dpmocks.NewMockGenericDataplane(ctrl) + f := newNetPolQueueFixture(t, policy, dp, false) + c := f.netPolController + key := getKey(policy, t) + + gomock.InOrder( + dp.EXPECT().UpdatePolicy(gomock.Any()).Return(fmt.Errorf("programming policy: %w", translation.ErrUnsupportedIPAddress)), + dp.EXPECT().UpdatePolicy(gomock.Any()).Return(nil), + ) + c.addNetworkPolicy(policy) + require.True(t, c.processNextWorkItem()) + require.Equal(t, 1, c.workqueue.NumRequeues(key)) + require.Equal(t, 1, c.workqueue.Len()) + require.Empty(t, c.rawNpSpecMap) + + require.True(t, c.processNextWorkItem()) + require.Zero(t, c.workqueue.NumRequeues(key)) + require.Zero(t, c.workqueue.Len()) + require.Equal(t, &policy.Spec, c.rawNpSpecMap[key]) +} + +func TestFullNPMRejectedUpdateRetainsAppliedPolicyUntilDeletion(t *testing.T) { + policy := netPolWithCIDR("192.0.2.0/24") + policy.ResourceVersion = "1" + ctrl := gomock.NewController(t) + dp := dpmocks.NewMockGenericDataplane(ctrl) + f := newNetPolQueueFixture(t, policy, dp, false) + c := f.netPolController + key := getKey(policy, t) + + dp.EXPECT().UpdatePolicy(gomock.Any()).Return(nil).Times(1) + c.addNetworkPolicy(policy) + require.True(t, c.processNextWorkItem()) + require.Equal(t, &policy.Spec, c.rawNpSpecMap[key]) + + rejected := policy.DeepCopy() + rejected.ResourceVersion = "2" + rejected.Spec.Ingress[0].From[0].IPBlock.CIDR = "192.0.2.0/33" + indexer := f.kubeInformer.Networking().V1().NetworkPolicies().Informer().GetIndexer() + require.NoError(t, indexer.Update(rejected)) + c.updateNetworkPolicy(policy, rejected) + require.Equal(t, 1, c.workqueue.Len()) + require.True(t, c.processNextWorkItem()) + require.Equal(t, &policy.Spec, c.rawNpSpecMap[key]) + require.Zero(t, c.workqueue.NumRequeues(key)) + require.Zero(t, c.workqueue.Len()) + + require.NoError(t, indexer.Delete(rejected)) + dp.EXPECT().RemovePolicy(key).Return(nil).Times(1) + c.deleteNetworkPolicy(rejected) + require.Equal(t, 1, c.workqueue.Len()) + require.True(t, c.processNextWorkItem()) + require.Empty(t, c.rawNpSpecMap) + require.Zero(t, c.workqueue.Len()) +} + +func TestFullNPMRejectedCreateCanBeDeleted(t *testing.T) { + policy := netPolWithCIDR("invalid") + ctrl := gomock.NewController(t) + dp := dpmocks.NewMockGenericDataplane(ctrl) + f := newNetPolQueueFixture(t, policy, dp, false) + c := f.netPolController + + c.addNetworkPolicy(policy) + require.True(t, c.processNextWorkItem()) + require.Zero(t, c.workqueue.Len()) + require.Empty(t, c.rawNpSpecMap) + + require.NoError(t, f.kubeInformer.Networking().V1().NetworkPolicies().Informer().GetIndexer().Delete(policy)) + c.deleteNetworkPolicy(policy) + require.Equal(t, 1, c.workqueue.Len()) + require.True(t, c.processNextWorkItem()) + require.Empty(t, c.rawNpSpecMap) + require.Zero(t, c.workqueue.Len()) +} + +func TestLiteTranslationFailureHandlingIsUnchanged(t *testing.T) { + policy := netPolWithCIDR("invalid") + ctrl := gomock.NewController(t) + dp := dpmocks.NewMockGenericDataplane(ctrl) + f := newNetPolQueueFixture(t, policy, dp, true) + c := f.netPolController + key := getKey(policy, t) + + _, err := c.syncAddAndUpdateNetPol(policy) + if util.IsWindowsDP() { + require.NoError(t, err, "the legacy Lite direct-address limitation stays suppressed") + } else { + require.ErrorIs(t, err, util.ErrInvalidCIDR) + require.NotErrorIs(t, err, errNetPolTranslationFailure) + } + + c.addNetworkPolicy(policy) + require.True(t, c.processNextWorkItem()) + require.Empty(t, c.rawNpSpecMap) + if util.IsWindowsDP() { + require.Zero(t, c.workqueue.NumRequeues(key)) + require.Zero(t, c.workqueue.Len()) + } else { + require.Equal(t, 1, c.workqueue.NumRequeues(key)) + require.Equal(t, 1, c.workqueue.Len()) + } +} diff --git a/npm/pkg/controlplane/controllers/v2/networkPolicyController_test.go b/npm/pkg/controlplane/controllers/v2/networkPolicyController_test.go index aa42d5c3757..54b7b072550 100644 --- a/npm/pkg/controlplane/controllers/v2/networkPolicyController_test.go +++ b/npm/pkg/controlplane/controllers/v2/networkPolicyController_test.go @@ -690,9 +690,10 @@ func TestSyncAddAndUpdateNetPolSurfacesTranslationFailure(t *testing.T) { } else { require.ErrorIs(t, err, translation.ErrUnsupportedIPAddress) require.ErrorIs(t, err, util.ErrUnsupportedIPFamily) + require.ErrorIs(t, err, errNetPolTranslationFailure) } - // The policy must not be recorded as applied, so a later retry still reconciles it. + // The policy must not be recorded as applied, so a later policy change reconciles it. netpolKey, keyErr := cache.MetaNamespaceKeyFunc(netPolObj) require.NoError(t, keyErr) require.NotContains(t, f.netPolController.rawNpSpecMap, netpolKey) @@ -723,5 +724,21 @@ func TestSyncAddAndUpdateNetPolSuppressesUnsupportedFeature(t *testing.T) { } func TestUnsupportedAddressClassificationIsPlatformSpecific(t *testing.T) { - require.Equal(t, util.IsWindowsDP(), isUnsupportedTranslationErr(translation.ErrUnsupportedIPAddress)) + for _, test := range []struct { + name string + err error + npmLite bool + want bool + }{ + {"full unclassified address", translation.ErrUnsupportedIPAddress, false, false}, + {"Lite unclassified address", translation.ErrUnsupportedIPAddress, true, util.IsWindowsDP()}, + {"full unsupported family", fmt.Errorf("%w: %w", translation.ErrUnsupportedIPAddress, util.ErrUnsupportedIPFamily), false, util.IsWindowsDP()}, + {"Lite unsupported family", fmt.Errorf("%w: %w", translation.ErrUnsupportedIPAddress, util.ErrUnsupportedIPFamily), true, util.IsWindowsDP()}, + {"full malformed CIDR", fmt.Errorf("%w: %w", translation.ErrUnsupportedIPAddress, util.ErrInvalidCIDR), false, false}, + {"Lite typed malformed CIDR", fmt.Errorf("%w: %w", translation.ErrUnsupportedIPAddress, util.ErrInvalidCIDR), true, false}, + } { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.want, isUnsupportedTranslationErr(test.err, test.npmLite)) + }) + } } diff --git a/npm/pkg/controlplane/translation/cidr_errors_test.go b/npm/pkg/controlplane/translation/cidr_errors_test.go index 4ce6a6655ac..df74470bb0e 100644 --- a/npm/pkg/controlplane/translation/cidr_errors_test.go +++ b/npm/pkg/controlplane/translation/cidr_errors_test.go @@ -11,30 +11,40 @@ import ( ) func TestIPBlockNormalizationErrorCauses(t *testing.T) { + const ( + ipv6CIDR = "2001:db8:2::/48" + malformedCIDR = "invalid" + ) for _, test := range []struct { - name string - block networkingv1.IPBlock - cause error + name string + block networkingv1.IPBlock + cause error + windowsExceptFailure bool }{ - {"invalid prefix", networkingv1.IPBlock{CIDR: "192.0.2.0/33"}, util.ErrInvalidCIDR}, - {"bare address", networkingv1.IPBlock{CIDR: "192.0.2.1"}, util.ErrInvalidCIDR}, - {"IPv6 prefix", networkingv1.IPBlock{CIDR: "2001:db8:2::/48"}, util.ErrUnsupportedIPFamily}, - {"mapped IPv6 prefix", networkingv1.IPBlock{CIDR: "::ffff:192.0.2.0/120"}, util.ErrUnsupportedIPFamily}, - {"invalid exclusion", networkingv1.IPBlock{CIDR: enclosingCIDR, Except: []string{"invalid"}}, util.ErrInvalidCIDR}, - {"IPv6 exclusion", networkingv1.IPBlock{CIDR: enclosingCIDR, Except: []string{"2001:db8:2::/48"}}, util.ErrUnsupportedIPFamily}, + {"invalid prefix", networkingv1.IPBlock{CIDR: "192.0.2.0/33"}, util.ErrInvalidCIDR, false}, + {"bare address", networkingv1.IPBlock{CIDR: "192.0.2.1"}, util.ErrInvalidCIDR, false}, + {"IPv6 prefix", networkingv1.IPBlock{CIDR: ipv6CIDR}, util.ErrUnsupportedIPFamily, false}, + {"mapped IPv6 prefix", networkingv1.IPBlock{CIDR: "::ffff:192.0.2.0/120"}, util.ErrUnsupportedIPFamily, false}, + {"invalid parent with exclusion", networkingv1.IPBlock{CIDR: "192.0.2.0/33", Except: []string{"192.0.2.1/32"}}, util.ErrInvalidCIDR, false}, + {"IPv6 parent with exclusion", networkingv1.IPBlock{CIDR: ipv6CIDR, Except: []string{"2001:db8:2::1/128"}}, util.ErrUnsupportedIPFamily, false}, + {"invalid parent and exclusion", networkingv1.IPBlock{CIDR: malformedCIDR, Except: []string{malformedCIDR}}, util.ErrInvalidCIDR, false}, + {"invalid exclusion", networkingv1.IPBlock{CIDR: enclosingCIDR, Except: []string{malformedCIDR}}, util.ErrInvalidCIDR, true}, + {"IPv6 exclusion", networkingv1.IPBlock{CIDR: enclosingCIDR, Except: []string{ipv6CIDR}}, util.ErrUnsupportedIPFamily, true}, } { t.Run(test.name, func(t *testing.T) { + unsupportedExcept := util.IsWindowsDP() && test.windowsExceptFailure before := test.block.DeepCopy() set, info, err := ipBlockRule("normalization", defaultNS, policies.Ingress, policies.SrcMatch, 0, 0, &test.block) require.Nil(t, set) require.Equal(t, policies.SetInfo{}, info) require.Equal(t, before, &test.block) - if util.IsWindowsDP() && len(test.block.Except) > 0 { + if unsupportedExcept { require.ErrorIs(t, err, ErrUnsupportedExceptCIDR) } else { require.ErrorIs(t, err, ErrUnsupportedIPAddress) require.ErrorIs(t, err, test.cause) + require.NotErrorIs(t, err, ErrUnsupportedExceptCIDR) } for _, direction := range []networkingv1.PolicyType{networkingv1.PolicyTypeIngress, networkingv1.PolicyTypeEgress} { @@ -52,7 +62,7 @@ func TestIPBlockNormalizationErrorCauses(t *testing.T) { } translated, policyErr := TranslatePolicy(policy, false) require.Nil(t, translated) - if util.IsWindowsDP() && len(test.block.Except) > 0 { + if unsupportedExcept { require.ErrorIs(t, policyErr, ErrUnsupportedExceptCIDR) } else { require.ErrorIs(t, policyErr, ErrUnsupportedIPAddress) diff --git a/npm/pkg/controlplane/translation/translatePolicy.go b/npm/pkg/controlplane/translation/translatePolicy.go index e54da48363a..e3602a3937f 100644 --- a/npm/pkg/controlplane/translation/translatePolicy.go +++ b/npm/pkg/controlplane/translation/translatePolicy.go @@ -227,8 +227,8 @@ func ipBlockIPSet(policyName, ns string, direction policies.Direction, ipBlockSe return nil, fmt.Errorf("ipBlock %q: %w: %w", ipBlockRule.CIDR, ErrUnsupportedIPAddress, err) } - // The Windows datapath refuses an except before any of it is canonicalized, exactly as - // it did before, so the validation below is reached on the Linux path only. + // Parent validation takes precedence, as it did in ipBlockRule before normalization + // moved here. Windows rejects the unsupported Except feature without parsing its CIDRs. if util.IsWindowsDP() && len(ipBlockRule.Except) > 0 { return nil, ErrUnsupportedExceptCIDR } diff --git a/npm/pkg/dataplane/debug/namespace_anchor_test.go b/npm/pkg/dataplane/debug/namespace_anchor_test.go new file mode 100644 index 00000000000..2f86d414021 --- /dev/null +++ b/npm/pkg/dataplane/debug/namespace_anchor_test.go @@ -0,0 +1,117 @@ +package debug + +import ( + "fmt" + "testing" + + common "github.com/Azure/azure-container-networking/npm/pkg/controlplane/controllers/common" + "github.com/Azure/azure-container-networking/npm/pkg/dataplane/pb" + "github.com/Azure/azure-container-networking/npm/util" + "github.com/stretchr/testify/require" +) + +const anchorPeerNamespace = "peer" + +func TestV2NamespaceAggregateMatch(t *testing.T) { + cache := &common.Cache{NsMap: map[string]*common.Namespace{anchorPeerNamespace: {}}} + for _, namespace := range []string{anchorPeerNamespace, "", "missing"} { + for _, included := range []bool{true, false} { + t.Run(fmt.Sprintf("namespace=%q/included=%t", namespace, included), func(t *testing.T) { + set := &pb.RuleResponse_SetInfo{ + Name: util.NamespaceLabelPrefix + util.KubeAllNamespacesFlagV2, + Type: pb.SetType_KEYLABELOFNAMESPACE, + Included: included, + } + matched, err := evaluateSetInfo("src", set, &common.NpmPod{Namespace: namespace}, &pb.RuleResponse{}, cache) + require.NoError(t, err) + require.Equal(t, included == (namespace == anchorPeerNamespace), matched) + }) + } + } +} + +func TestNamespaceAnchorRulesRequireEveryNamespaceMatch(t *testing.T) { + orders := [][3]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}} + for _, direction := range []pb.Direction{pb.Direction_INGRESS, pb.Direction_EGRESS} { + for _, tenant := range []string{"a", "b", "good", ""} { + for _, order := range orders { + t.Run(fmt.Sprintf("%s/tenant=%q/order=%v", direction, tenant, order), func(t *testing.T) { + peer := &common.NpmPod{Namespace: anchorPeerNamespace} + target := &common.NpmPod{Namespace: "target"} + cache := &common.Cache{ + NsMap: map[string]*common.Namespace{ + anchorPeerNamespace: {LabelsMap: map[string]string{"tenant": tenant}}, + }, + } + + allMatches := []*pb.RuleResponse_SetInfo{ + {Name: util.NamespaceLabelPrefix + "tenant:a", HashedSetName: "tenant-a", Type: pb.SetType_KEYVALUELABELOFNAMESPACE}, + {Name: util.NamespaceLabelPrefix + "tenant:b", HashedSetName: "tenant-b", Type: pb.SetType_KEYVALUELABELOFNAMESPACE}, + { + Name: util.NamespaceLabelPrefix + util.KubeAllNamespacesFlagV2, HashedSetName: "aggregate", + Type: pb.SetType_KEYLABELOFNAMESPACE, Included: true, + }, + } + converter := &Converter{EnableV2NPM: true} + for _, set := range allMatches { + set.Type, _ = converter.getSetTypeV2(set.GetName()) + } + peerMatches := []*pb.RuleResponse_SetInfo{allMatches[order[0]], allMatches[order[1]], allMatches[order[2]]} + targetMatches := []*pb.RuleResponse_SetInfo{{ + Name: util.NamespacePrefix + "target", HashedSetName: "target", Type: pb.SetType_NAMESPACE, Included: true, + }} + allow := &pb.RuleResponse{Allowed: true, Direction: direction, Chain: "allow"} + deny := &pb.RuleResponse{Direction: direction, Chain: "deny"} + src, dst := peer, target + if direction == pb.Direction_INGRESS { + allow.SrcList, allow.DstList = peerMatches, targetMatches + deny.DstList = targetMatches + } else { + src, dst = target, peer + allow.SrcList, allow.DstList = targetMatches, peerMatches + deny.SrcList = targetMatches + } + rules := map[*pb.RuleResponse]struct{}{allow: {}, deny: {}} + hits, _, _, err := getHitRules(src, dst, rules, cache) + require.NoError(t, err) + want := []*pb.RuleResponse{deny} + if tenant != "a" && tenant != "b" { + want = append(want, allow) + } + require.ElementsMatch(t, want, hits) + + peer.Namespace = "" + hits, _, _, err = getHitRules(src, dst, rules, cache) + require.NoError(t, err) + require.ElementsMatch(t, []*pb.RuleResponse{deny}, hits, "the aggregate must not match an external endpoint") + }) + } + } + } +} + +func TestNamespaceAnchorConditionsDistinguishLabelPresence(t *testing.T) { + for _, test := range []struct { + name string + labels map[string]string + want bool + }{ + {"missing label", map[string]string{}, true}, + {"empty value", map[string]string{util.KubeAllNamespacesFlag: ""}, false}, + {"nonempty value", map[string]string{util.KubeAllNamespacesFlag: "yes"}, false}, + } { + t.Run(test.name, func(t *testing.T) { + cache := &common.Cache{NsMap: map[string]*common.Namespace{ + anchorPeerNamespace: {LabelsMap: test.labels}, + util.NamespacePrefix + anchorPeerNamespace: {LabelsMap: map[string]string{util.KubeAllNamespacesFlag: "other"}}, + }} + sets := []*pb.RuleResponse_SetInfo{ + {Name: util.NamespaceLabelPrefix + util.KubeAllNamespacesFlag, Type: pb.SetType_KEYLABELOFNAMESPACE}, + {Name: util.NamespaceLabelPrefix + util.KubeAllNamespacesFlagV2, Type: pb.SetType_KEYLABELOFNAMESPACE, Included: true}, + } + matched, err := matchNamespaceAnchorConditions(&common.NpmPod{Namespace: anchorPeerNamespace}, sets, cache) + require.NoError(t, err) + require.Equal(t, test.want, matched) + }) + } +} diff --git a/npm/pkg/dataplane/debug/trafficanalyzer.go b/npm/pkg/dataplane/debug/trafficanalyzer.go index 2462989e12f..a10182fd37c 100644 --- a/npm/pkg/dataplane/debug/trafficanalyzer.go +++ b/npm/pkg/dataplane/debug/trafficanalyzer.go @@ -140,7 +140,7 @@ func getNetworkTupleCommon( ruleResListJSON := make([][]byte, 0) m := protojson.MarshalOptions{ - Indent: " ", + Indent: " ", EmitUnpopulated: true, } for _, rule := range hitRules { @@ -227,6 +227,17 @@ func getHitRules( dstSets := make(map[string]*pb.RuleResponse_SetInfo, 0) for rule := range rules { + srcNamespaceMatch, err := matchNamespaceAnchorConditions(src, rule.GetSrcList(), npmCache) + if err != nil { + return nil, nil, nil, fmt.Errorf("evaluating source namespace conditions: %w", err) + } + dstNamespaceMatch, err := matchNamespaceAnchorConditions(dst, rule.GetDstList(), npmCache) + if err != nil { + return nil, nil, nil, fmt.Errorf("evaluating destination namespace conditions: %w", err) + } + if !srcNamespaceMatch || !dstNamespaceMatch { + continue + } matchedSrc := false matchedDst := false // evalute all match set in src @@ -259,7 +270,6 @@ func getHitRules( return nil, nil, nil, fmt.Errorf("error occurred during evaluating destination's set info : %w", err) } if matchedDestination { - dstSets[setInfo.HashedSetName] = setInfo matchedDst = true break @@ -285,6 +295,52 @@ func getHitRules( return res, srcSets, dstSets, nil } +// An aggregate match must not override the namespace exclusions beside it. +// Handle this v2 conjunction without changing legacy matching for unrelated sets. +func matchNamespaceAnchorConditions(pod *common.NpmPod, sets []*pb.RuleResponse_SetInfo, npmCache common.GenericCache) (bool, error) { + hasAnchor := false + for _, set := range sets { + if set.GetName() == util.NamespaceLabelPrefix+util.KubeAllNamespacesFlagV2 { + hasAnchor = true + break + } + } + if !hasAnchor { + return true, nil + } + + labels, namespaceExists := npmCache.GetNamespaceLabels(pod.Namespace) + for _, set := range sets { + var matches bool + switch set.GetType() { + case pb.SetType_KEYLABELOFNAMESPACE, pb.SetType_KEYVALUELABELOFNAMESPACE: + if set.GetName() == util.NamespaceLabelPrefix+util.KubeAllNamespacesFlagV2 { + matches = pod.Namespace != "" && namespaceExists + } else { + name, ok := strings.CutPrefix(set.GetName(), util.NamespaceLabelPrefix) + // The converter uses the same set type for key and key:value namespace sets. + key, value, hasValue := strings.Cut(name, ":") + if !ok || key == "" { + return false, fmt.Errorf("namespace label set %q: %w", set.GetName(), common.ErrInvalidInput) + } + actual, exists := labels[key] + matches = exists && (!hasValue || actual == value) + } + case pb.SetType_NAMESPACE: + matches = set.GetName() == util.NamespacePrefix+pod.Namespace + case pb.SetType_KEYLABELOFPOD, pb.SetType_KEYVALUELABELOFPOD, pb.SetType_NAMEDPORTS, + pb.SetType_NESTEDLABELOFPOD, pb.SetType_CIDRBLOCKS, pb.SetType_UNKNOWN: + continue + default: + return false, fmt.Errorf("namespace condition type %v: %w", set.GetType(), common.ErrSetType) + } + if matches != set.GetIncluded() { + return false, nil + } + } + return true, nil +} + // evalute an ipset to find out whether the pod's attributes match with the set func evaluateSetInfo( origin string, @@ -358,6 +414,10 @@ func matchNESTEDLABELOFPOD(pod *common.NpmPod, setInfo *pb.RuleResponse_SetInfo) } func matchKEYLABELOFNAMESPACE(pod *common.NpmPod, npmCache common.GenericCache, setInfo *pb.RuleResponse_SetInfo) bool { + if setInfo.GetName() == util.NamespaceLabelPrefix+util.KubeAllNamespacesFlagV2 { + _, namespaceExists := npmCache.GetNamespaceLabels(pod.Namespace) + return setInfo.GetIncluded() == (pod.Namespace != "" && namespaceExists) + } srcNamespace := pod.Namespace key := strings.Split(strings.TrimPrefix(setInfo.Name, util.NamespaceLabelPrefix), ":") included := npmCache.GetNamespaceLabel(srcNamespace, key[0]) From bc1278ffde0937fee3a3e81e71024203e8b71346 Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Mon, 14 Sep 2026 22:54:12 +0000 Subject: [PATCH 22/33] fix: [NPM] retain combined peer conditions in v2 diagnostics Keep pod and port conditions conjunctive with the v2 namespace aggregate. Report incomplete diagnostic set metadata instead of letting the aggregate override unresolved conditions. Leave unrelated legacy matching unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 175c523d-ad1c-4e31-986d-7c9940c6c18c --- .../dataplane/debug/namespace_anchor_test.go | 71 ++++++++++++++++++- npm/pkg/dataplane/debug/trafficanalyzer.go | 52 ++++++++++---- 2 files changed, 110 insertions(+), 13 deletions(-) diff --git a/npm/pkg/dataplane/debug/namespace_anchor_test.go b/npm/pkg/dataplane/debug/namespace_anchor_test.go index 2f86d414021..bc464707bee 100644 --- a/npm/pkg/dataplane/debug/namespace_anchor_test.go +++ b/npm/pkg/dataplane/debug/namespace_anchor_test.go @@ -109,9 +109,78 @@ func TestNamespaceAnchorConditionsDistinguishLabelPresence(t *testing.T) { {Name: util.NamespaceLabelPrefix + util.KubeAllNamespacesFlag, Type: pb.SetType_KEYLABELOFNAMESPACE}, {Name: util.NamespaceLabelPrefix + util.KubeAllNamespacesFlagV2, Type: pb.SetType_KEYLABELOFNAMESPACE, Included: true}, } - matched, err := matchNamespaceAnchorConditions(&common.NpmPod{Namespace: anchorPeerNamespace}, sets, cache) + matched, err := matchNamespaceAnchorConditions("src", &common.NpmPod{Namespace: anchorPeerNamespace}, sets, &pb.RuleResponse{}, cache) require.NoError(t, err) require.Equal(t, test.want, matched) }) } } + +func TestNamespaceAnchorDoesNotOverridePodSelection(t *testing.T) { + cache := &common.Cache{NsMap: map[string]*common.Namespace{anchorPeerNamespace: {}}} + peer := &common.NpmPod{Namespace: anchorPeerNamespace, Labels: map[string]string{"app": "other"}} + target := &common.NpmPod{Namespace: "target"} + converter := &Converter{EnableV2NPM: true} + podSet := &pb.RuleResponse_SetInfo{ + Name: util.PodLabelPrefix + "app:required", Included: true, + } + podSet.Type, _ = converter.getSetTypeV2(podSet.GetName()) + targetSet := &pb.RuleResponse_SetInfo{ + Name: util.NamespacePrefix + "target", Type: pb.SetType_NAMESPACE, Included: true, + } + allow := &pb.RuleResponse{ + Allowed: true, + SrcList: []*pb.RuleResponse_SetInfo{ + {Name: util.NamespaceLabelPrefix + util.KubeAllNamespacesFlagV2, Type: pb.SetType_KEYLABELOFNAMESPACE, Included: true}, + podSet, + }, + DstList: []*pb.RuleResponse_SetInfo{targetSet}, + } + deny := &pb.RuleResponse{DstList: []*pb.RuleResponse_SetInfo{targetSet}} + rules := map[*pb.RuleResponse]struct{}{allow: {}, deny: {}} + hits, _, _, err := getHitRules(peer, target, rules, cache) + require.NoError(t, err) + require.ElementsMatch(t, []*pb.RuleResponse{deny}, hits) + + peer.Labels["app"] = "required" + hits, _, _, err = getHitRules(peer, target, rules, cache) + require.NoError(t, err) + require.ElementsMatch(t, []*pb.RuleResponse{allow, deny}, hits) +} + +func TestNamespaceAnchorConditionsReportIncompleteSets(t *testing.T) { + cache := &common.Cache{NsMap: map[string]*common.Namespace{anchorPeerNamespace: {}}} + anchor := &pb.RuleResponse_SetInfo{ + Name: util.NamespaceLabelPrefix + util.KubeAllNamespacesFlagV2, Type: pb.SetType_KEYLABELOFNAMESPACE, Included: true, + } + for _, test := range []struct { + name string + set *pb.RuleResponse_SetInfo + cause error + }{ + { + "nested identity without values", + &pb.RuleResponse_SetInfo{Name: util.NestedLabelPrefix + "policy:key", Type: pb.SetType_NESTEDLABELOFPOD, Included: true}, + common.ErrInvalidInput, + }, + { + "unknown set type", + &pb.RuleResponse_SetInfo{Name: "unknown", Type: pb.SetType_UNKNOWN, Included: true}, + common.ErrSetType, + }, + { + "missing label key", + &pb.RuleResponse_SetInfo{Name: util.PodLabelPrefix + ":value", Type: pb.SetType_KEYLABELOFPOD, Included: true}, + common.ErrInvalidInput, + }, + } { + t.Run(test.name, func(t *testing.T) { + matched, err := matchNamespaceAnchorConditions( + "src", &common.NpmPod{Namespace: anchorPeerNamespace}, + []*pb.RuleResponse_SetInfo{anchor, test.set}, &pb.RuleResponse{}, cache, + ) + require.False(t, matched) + require.ErrorIs(t, err, test.cause) + }) + } +} diff --git a/npm/pkg/dataplane/debug/trafficanalyzer.go b/npm/pkg/dataplane/debug/trafficanalyzer.go index a10182fd37c..f259fa6f7e3 100644 --- a/npm/pkg/dataplane/debug/trafficanalyzer.go +++ b/npm/pkg/dataplane/debug/trafficanalyzer.go @@ -227,11 +227,11 @@ func getHitRules( dstSets := make(map[string]*pb.RuleResponse_SetInfo, 0) for rule := range rules { - srcNamespaceMatch, err := matchNamespaceAnchorConditions(src, rule.GetSrcList(), npmCache) + srcNamespaceMatch, err := matchNamespaceAnchorConditions("src", src, rule.GetSrcList(), rule, npmCache) if err != nil { return nil, nil, nil, fmt.Errorf("evaluating source namespace conditions: %w", err) } - dstNamespaceMatch, err := matchNamespaceAnchorConditions(dst, rule.GetDstList(), npmCache) + dstNamespaceMatch, err := matchNamespaceAnchorConditions("dst", dst, rule.GetDstList(), rule, npmCache) if err != nil { return nil, nil, nil, fmt.Errorf("evaluating destination namespace conditions: %w", err) } @@ -295,9 +295,9 @@ func getHitRules( return res, srcSets, dstSets, nil } -// An aggregate match must not override the namespace exclusions beside it. +// An aggregate match must not override the other peer conditions beside it. // Handle this v2 conjunction without changing legacy matching for unrelated sets. -func matchNamespaceAnchorConditions(pod *common.NpmPod, sets []*pb.RuleResponse_SetInfo, npmCache common.GenericCache) (bool, error) { +func matchNamespaceAnchorConditions(origin string, pod *common.NpmPod, sets []*pb.RuleResponse_SetInfo, rule *pb.RuleResponse, npmCache common.GenericCache) (bool, error) { hasAnchor := false for _, set := range sets { if set.GetName() == util.NamespaceLabelPrefix+util.KubeAllNamespacesFlagV2 { @@ -317,20 +317,38 @@ func matchNamespaceAnchorConditions(pod *common.NpmPod, sets []*pb.RuleResponse_ if set.GetName() == util.NamespaceLabelPrefix+util.KubeAllNamespacesFlagV2 { matches = pod.Namespace != "" && namespaceExists } else { - name, ok := strings.CutPrefix(set.GetName(), util.NamespaceLabelPrefix) // The converter uses the same set type for key and key:value namespace sets. - key, value, hasValue := strings.Cut(name, ":") - if !ok || key == "" { - return false, fmt.Errorf("namespace label set %q: %w", set.GetName(), common.ErrInvalidInput) + var err error + matches, err = matchPrefixedLabelSet(labels, set.GetName(), util.NamespaceLabelPrefix) + if err != nil { + return false, err } - actual, exists := labels[key] - matches = exists && (!hasValue || actual == value) } case pb.SetType_NAMESPACE: matches = set.GetName() == util.NamespacePrefix+pod.Namespace - case pb.SetType_KEYLABELOFPOD, pb.SetType_KEYVALUELABELOFPOD, pb.SetType_NAMEDPORTS, - pb.SetType_NESTEDLABELOFPOD, pb.SetType_CIDRBLOCKS, pb.SetType_UNKNOWN: + case pb.SetType_KEYLABELOFPOD, pb.SetType_KEYVALUELABELOFPOD: + var err error + matches, err = matchPrefixedLabelSet(pod.Labels, set.GetName(), util.PodLabelPrefix) + if err != nil { + return false, err + } + case pb.SetType_NAMEDPORTS: + if !matchNAMEDPORTS(pod, set, rule, origin) { + return false, nil + } continue + case pb.SetType_NESTEDLABELOFPOD: + // Current nested identities carry a policy/key, not their allowed values. + // Only older value-encoded names can be evaluated from this cache format. + if strings.Count(set.GetName(), util.IpsetLabelDelimter) < 2 { + return false, fmt.Errorf("missing nested label values for %q: %w", set.GetName(), common.ErrInvalidInput) + } + if !matchNESTEDLABELOFPOD(pod, set) { + return false, nil + } + continue + case pb.SetType_CIDRBLOCKS, pb.SetType_UNKNOWN: + return false, fmt.Errorf("unsupported anchored set %q: %w", set.GetName(), common.ErrSetType) default: return false, fmt.Errorf("namespace condition type %v: %w", set.GetType(), common.ErrSetType) } @@ -341,6 +359,16 @@ func matchNamespaceAnchorConditions(pod *common.NpmPod, sets []*pb.RuleResponse_ return true, nil } +func matchPrefixedLabelSet(labels map[string]string, setName, prefix string) (bool, error) { + name, ok := strings.CutPrefix(setName, prefix) + key, value, hasValue := strings.Cut(name, ":") + if !ok || key == "" { + return false, fmt.Errorf("label set %q: %w", setName, common.ErrInvalidInput) + } + actual, exists := labels[key] + return exists && (!hasValue || actual == value), nil +} + // evalute an ipset to find out whether the pod's attributes match with the set func evaluateSetInfo( origin string, From 0ebf2839f046d688fa8a7450da8119541e6bbc6e Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Mon, 14 Sep 2026 23:19:01 +0000 Subject: [PATCH 23/33] fix: [NPM] evaluate all v2 namespace conditions consistently Handle key-only namespace labels by presence and retain conjunctions for mixed positive and negative namespace selectors. Keep v1 metadata on its existing path and update the tuple fixture to exclude namespace-mismatched TCP rules. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 175c523d-ad1c-4e31-986d-7c9940c6c18c --- .../dataplane/debug/namespace_anchor_test.go | 99 +++++++++++++++++-- npm/pkg/dataplane/debug/trafficanalyzer.go | 41 +++++--- .../dataplane/debug/trafficanalyzer_test.go | 20 +--- 3 files changed, 122 insertions(+), 38 deletions(-) diff --git a/npm/pkg/dataplane/debug/namespace_anchor_test.go b/npm/pkg/dataplane/debug/namespace_anchor_test.go index bc464707bee..53dccd78c88 100644 --- a/npm/pkg/dataplane/debug/namespace_anchor_test.go +++ b/npm/pkg/dataplane/debug/namespace_anchor_test.go @@ -10,7 +10,12 @@ import ( "github.com/stretchr/testify/require" ) -const anchorPeerNamespace = "peer" +const ( + anchorPeerNamespace = "peer" + anchorTargetNamespace = "target" + matchedTeamValue = "blue" + otherLabelValue = "other" +) func TestV2NamespaceAggregateMatch(t *testing.T) { cache := &common.Cache{NsMap: map[string]*common.Namespace{anchorPeerNamespace: {}}} @@ -30,6 +35,86 @@ func TestV2NamespaceAggregateMatch(t *testing.T) { } } +func TestV2NamespaceKeyOnlyMatch(t *testing.T) { + for _, test := range []struct { + name string + labels map[string]string + present bool + }{ + {"absent", nil, false}, + {"empty value", map[string]string{"feature": ""}, true}, + {"nonempty value", map[string]string{"feature": "enabled"}, true}, + } { + for _, included := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/included=%t", test.name, included), func(t *testing.T) { + cache := &common.Cache{NsMap: map[string]*common.Namespace{ + anchorPeerNamespace: {LabelsMap: test.labels}, + }} + set := &pb.RuleResponse_SetInfo{ + Name: util.NamespaceLabelPrefix + "feature", Type: pb.SetType_KEYLABELOFNAMESPACE, Included: included, + } + matched, err := evaluateSetInfo("src", set, &common.NpmPod{Namespace: anchorPeerNamespace}, &pb.RuleResponse{}, cache) + require.NoError(t, err) + require.Equal(t, included == test.present, matched) + }) + } + } +} + +func TestV1NamespaceMatchingIsUnchanged(t *testing.T) { + cache := &common.Cache{NsMap: map[string]*common.Namespace{ + util.NamespacePrefix + anchorPeerNamespace: {LabelsMap: map[string]string{"team": matchedTeamValue}}, + }} + pod := &common.NpmPod{ + Namespace: anchorPeerNamespace, + Labels: map[string]string{"nslabel-team": matchedTeamValue}, + } + for _, set := range []*pb.RuleResponse_SetInfo{ + {Name: util.NamespacePrefix + "team:blue", Type: pb.SetType_KEYVALUELABELOFNAMESPACE, Included: true}, + {Name: "nslabel-team:blue", Type: pb.SetType_KEYVALUELABELOFPOD, Included: true}, + } { + matched, err := matchNamespaceAnchorConditions("src", pod, []*pb.RuleResponse_SetInfo{set}, &pb.RuleResponse{}, cache) + require.NoError(t, err) + require.True(t, matched, "the v2 pre-check must leave v1 metadata alone") + matched, err = evaluateSetInfo("src", set, pod, &pb.RuleResponse{}, cache) + require.NoError(t, err) + require.True(t, matched) + } +} + +func TestV2MixedNamespaceConditionsAreConjunctive(t *testing.T) { + for _, tenant := range []string{"x", "y", otherLabelValue} { + for _, positiveFirst := range []bool{true, false} { + t.Run(fmt.Sprintf("tenant=%s/positiveFirst=%t", tenant, positiveFirst), func(t *testing.T) { + cache := &common.Cache{NsMap: map[string]*common.Namespace{ + anchorPeerNamespace: {LabelsMap: map[string]string{"team": matchedTeamValue, "tenant": tenant}}, + }} + positive := &pb.RuleResponse_SetInfo{Name: util.NamespaceLabelPrefix + "team:blue", Type: pb.SetType_KEYLABELOFNAMESPACE, Included: true} + excludeX := &pb.RuleResponse_SetInfo{Name: util.NamespaceLabelPrefix + "tenant:x", Type: pb.SetType_KEYLABELOFNAMESPACE} + excludeY := &pb.RuleResponse_SetInfo{Name: util.NamespaceLabelPrefix + "tenant:y", Type: pb.SetType_KEYLABELOFNAMESPACE} + sets := []*pb.RuleResponse_SetInfo{positive, excludeX, excludeY} + if !positiveFirst { + sets = []*pb.RuleResponse_SetInfo{excludeX, excludeY, positive} + } + allow := &pb.RuleResponse{Allowed: true, SrcList: sets} + deny := &pb.RuleResponse{DstList: []*pb.RuleResponse_SetInfo{{ + Name: util.NamespacePrefix + anchorTargetNamespace, Type: pb.SetType_NAMESPACE, Included: true, + }}} + hits, _, _, err := getHitRules( + &common.NpmPod{Namespace: anchorPeerNamespace}, &common.NpmPod{Namespace: anchorTargetNamespace}, + map[*pb.RuleResponse]struct{}{allow: {}, deny: {}}, cache, + ) + require.NoError(t, err) + want := []*pb.RuleResponse{deny} + if tenant == otherLabelValue { + want = append(want, allow) + } + require.ElementsMatch(t, want, hits) + }) + } + } +} + func TestNamespaceAnchorRulesRequireEveryNamespaceMatch(t *testing.T) { orders := [][3]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}} for _, direction := range []pb.Direction{pb.Direction_INGRESS, pb.Direction_EGRESS} { @@ -37,7 +122,7 @@ func TestNamespaceAnchorRulesRequireEveryNamespaceMatch(t *testing.T) { for _, order := range orders { t.Run(fmt.Sprintf("%s/tenant=%q/order=%v", direction, tenant, order), func(t *testing.T) { peer := &common.NpmPod{Namespace: anchorPeerNamespace} - target := &common.NpmPod{Namespace: "target"} + target := &common.NpmPod{Namespace: anchorTargetNamespace} cache := &common.Cache{ NsMap: map[string]*common.Namespace{ anchorPeerNamespace: {LabelsMap: map[string]string{"tenant": tenant}}, @@ -58,7 +143,7 @@ func TestNamespaceAnchorRulesRequireEveryNamespaceMatch(t *testing.T) { } peerMatches := []*pb.RuleResponse_SetInfo{allMatches[order[0]], allMatches[order[1]], allMatches[order[2]]} targetMatches := []*pb.RuleResponse_SetInfo{{ - Name: util.NamespacePrefix + "target", HashedSetName: "target", Type: pb.SetType_NAMESPACE, Included: true, + Name: util.NamespacePrefix + anchorTargetNamespace, HashedSetName: anchorTargetNamespace, Type: pb.SetType_NAMESPACE, Included: true, }} allow := &pb.RuleResponse{Allowed: true, Direction: direction, Chain: "allow"} deny := &pb.RuleResponse{Direction: direction, Chain: "deny"} @@ -103,7 +188,7 @@ func TestNamespaceAnchorConditionsDistinguishLabelPresence(t *testing.T) { t.Run(test.name, func(t *testing.T) { cache := &common.Cache{NsMap: map[string]*common.Namespace{ anchorPeerNamespace: {LabelsMap: test.labels}, - util.NamespacePrefix + anchorPeerNamespace: {LabelsMap: map[string]string{util.KubeAllNamespacesFlag: "other"}}, + util.NamespacePrefix + anchorPeerNamespace: {LabelsMap: map[string]string{util.KubeAllNamespacesFlag: otherLabelValue}}, }} sets := []*pb.RuleResponse_SetInfo{ {Name: util.NamespaceLabelPrefix + util.KubeAllNamespacesFlag, Type: pb.SetType_KEYLABELOFNAMESPACE}, @@ -118,15 +203,15 @@ func TestNamespaceAnchorConditionsDistinguishLabelPresence(t *testing.T) { func TestNamespaceAnchorDoesNotOverridePodSelection(t *testing.T) { cache := &common.Cache{NsMap: map[string]*common.Namespace{anchorPeerNamespace: {}}} - peer := &common.NpmPod{Namespace: anchorPeerNamespace, Labels: map[string]string{"app": "other"}} - target := &common.NpmPod{Namespace: "target"} + peer := &common.NpmPod{Namespace: anchorPeerNamespace, Labels: map[string]string{"app": otherLabelValue}} + target := &common.NpmPod{Namespace: anchorTargetNamespace} converter := &Converter{EnableV2NPM: true} podSet := &pb.RuleResponse_SetInfo{ Name: util.PodLabelPrefix + "app:required", Included: true, } podSet.Type, _ = converter.getSetTypeV2(podSet.GetName()) targetSet := &pb.RuleResponse_SetInfo{ - Name: util.NamespacePrefix + "target", Type: pb.SetType_NAMESPACE, Included: true, + Name: util.NamespacePrefix + anchorTargetNamespace, Type: pb.SetType_NAMESPACE, Included: true, } allow := &pb.RuleResponse{ Allowed: true, diff --git a/npm/pkg/dataplane/debug/trafficanalyzer.go b/npm/pkg/dataplane/debug/trafficanalyzer.go index f259fa6f7e3..5c6d4e3c081 100644 --- a/npm/pkg/dataplane/debug/trafficanalyzer.go +++ b/npm/pkg/dataplane/debug/trafficanalyzer.go @@ -231,11 +231,14 @@ func getHitRules( if err != nil { return nil, nil, nil, fmt.Errorf("evaluating source namespace conditions: %w", err) } + if !srcNamespaceMatch { + continue + } dstNamespaceMatch, err := matchNamespaceAnchorConditions("dst", dst, rule.GetDstList(), rule, npmCache) if err != nil { return nil, nil, nil, fmt.Errorf("evaluating destination namespace conditions: %w", err) } - if !srcNamespaceMatch || !dstNamespaceMatch { + if !dstNamespaceMatch { continue } matchedSrc := false @@ -295,17 +298,18 @@ func getHitRules( return res, srcSets, dstSets, nil } -// An aggregate match must not override the other peer conditions beside it. -// Handle this v2 conjunction without changing legacy matching for unrelated sets. +// V2 namespace conditions are conjunctive, whether or not an aggregate was needed. +// The namespace-label prefix and type keep legacy v1 matching outside this path. func matchNamespaceAnchorConditions(origin string, pod *common.NpmPod, sets []*pb.RuleResponse_SetInfo, rule *pb.RuleResponse, npmCache common.GenericCache) (bool, error) { - hasAnchor := false + hasV2Namespace := false for _, set := range sets { - if set.GetName() == util.NamespaceLabelPrefix+util.KubeAllNamespacesFlagV2 { - hasAnchor = true + if (set.GetType() == pb.SetType_KEYLABELOFNAMESPACE || set.GetType() == pb.SetType_KEYVALUELABELOFNAMESPACE) && + strings.HasPrefix(set.GetName(), util.NamespaceLabelPrefix) { + hasV2Namespace = true break } } - if !hasAnchor { + if !hasV2Namespace { return true, nil } @@ -380,11 +384,14 @@ func evaluateSetInfo( switch setInfo.Type { case pb.SetType_KEYVALUELABELOFNAMESPACE: + if strings.HasPrefix(setInfo.GetName(), util.NamespaceLabelPrefix) { + return matchKEYLABELOFNAMESPACE(pod, npmCache, setInfo) + } return matchKEYVALUELABELOFNAMESPACE(pod, npmCache, setInfo), nil case pb.SetType_NESTEDLABELOFPOD: return matchNESTEDLABELOFPOD(pod, setInfo), nil case pb.SetType_KEYLABELOFNAMESPACE: - return matchKEYLABELOFNAMESPACE(pod, npmCache, setInfo), nil + return matchKEYLABELOFNAMESPACE(pod, npmCache, setInfo) case pb.SetType_NAMESPACE: return matchNAMESPACE(pod, setInfo), nil case pb.SetType_KEYVALUELABELOFPOD: @@ -441,22 +448,30 @@ func matchNESTEDLABELOFPOD(pod *common.NpmPod, setInfo *pb.RuleResponse_SetInfo) return true } -func matchKEYLABELOFNAMESPACE(pod *common.NpmPod, npmCache common.GenericCache, setInfo *pb.RuleResponse_SetInfo) bool { +func matchKEYLABELOFNAMESPACE(pod *common.NpmPod, npmCache common.GenericCache, setInfo *pb.RuleResponse_SetInfo) (bool, error) { if setInfo.GetName() == util.NamespaceLabelPrefix+util.KubeAllNamespacesFlagV2 { _, namespaceExists := npmCache.GetNamespaceLabels(pod.Namespace) - return setInfo.GetIncluded() == (pod.Namespace != "" && namespaceExists) + return setInfo.GetIncluded() == (pod.Namespace != "" && namespaceExists), nil + } + if strings.HasPrefix(setInfo.GetName(), util.NamespaceLabelPrefix) { + labels, _ := npmCache.GetNamespaceLabels(pod.Namespace) + matches, err := matchPrefixedLabelSet(labels, setInfo.GetName(), util.NamespaceLabelPrefix) + if err != nil { + return false, err + } + return matches == setInfo.GetIncluded(), nil } srcNamespace := pod.Namespace key := strings.Split(strings.TrimPrefix(setInfo.Name, util.NamespaceLabelPrefix), ":") included := npmCache.GetNamespaceLabel(srcNamespace, key[0]) if included != "" && included == key[1] { - return setInfo.Included + return setInfo.GetIncluded(), nil } if setInfo.Included { // if key does not exist but required in rule - return false + return false, nil } - return true + return true, nil } func matchNAMESPACE(pod *common.NpmPod, setInfo *pb.RuleResponse_SetInfo) bool { diff --git a/npm/pkg/dataplane/debug/trafficanalyzer_test.go b/npm/pkg/dataplane/debug/trafficanalyzer_test.go index 0e21bb172a7..909b1c126be 100644 --- a/npm/pkg/dataplane/debug/trafficanalyzer_test.go +++ b/npm/pkg/dataplane/debug/trafficanalyzer_test.go @@ -65,16 +65,9 @@ func TestGetNetworkTuple(t *testing.T) { dst: &common.Input{Content: "x/b", Type: common.NSPODNAME}, } + // The TCP/80 rules require destination namespace y or z as well as the pod label. + // Destination x/b must not be reported as a hit just because its pod label matches. expected0 := []*Tuple{ - { - RuleType: "ALLOWED", - Direction: "EGRESS", - SrcIP: "10.224.0.17", - SrcPort: "ANY", - DstIP: "10.224.0.20", - DstPort: "80", - Protocol: "tcp", - }, { RuleType: "ALLOWED", Direction: "EGRESS", @@ -93,15 +86,6 @@ func TestGetNetworkTuple(t *testing.T) { DstPort: "53", Protocol: "tcp", }, - { - RuleType: "ALLOWED", - Direction: "EGRESS", - SrcIP: "10.224.0.17", - SrcPort: "ANY", - DstIP: "10.224.0.20", - DstPort: "80", - Protocol: "tcp", - }, { RuleType: "NOT ALLOWED", Direction: "EGRESS", From 3a70439a092501a7666f56d5ab05f2a7ae0ff344 Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Tue, 15 Sep 2026 00:16:15 +0000 Subject: [PATCH 24/33] fix: [NPM] use explicit version mode in peer diagnostics Pass the converter mode through diagnostic evaluation, include namespace constraints for pod-only peers, and decode policy-scoped v2 nested selectors using the actual label key and values. Preserve legacy v1 dispatch and clarify that Lite direct allocation is outside the full-NPM work-budget guarantee. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 175c523d-ad1c-4e31-986d-7c9940c6c18c --- .../translation/translatePolicy.go | 4 +- npm/pkg/dataplane/debug/converter_test.go | 2 +- .../dataplane/debug/namespace_anchor_test.go | 22 ++--- npm/pkg/dataplane/debug/trafficanalyzer.go | 92 +++++++++++++----- .../dataplane/debug/trafficanalyzer_test.go | 9 +- npm/pkg/dataplane/debug/version_mode_test.go | 96 +++++++++++++++++++ npm/pkg/dataplane/testdata/npmcachev2.json | 4 +- 7 files changed, 185 insertions(+), 44 deletions(-) create mode 100644 npm/pkg/dataplane/debug/version_mode_test.go diff --git a/npm/pkg/controlplane/translation/translatePolicy.go b/npm/pkg/controlplane/translation/translatePolicy.go index e3602a3937f..da8c2f1d2a4 100644 --- a/npm/pkg/controlplane/translation/translatePolicy.go +++ b/npm/pkg/controlplane/translation/translatePolicy.go @@ -857,7 +857,9 @@ func TranslatePolicy(npObj *networkingv1.NetworkPolicy, npmLiteToggle bool) (*po return npmNetPol, nil } -// maxACLsPerPolicy bounds how many ACLs a single NetworkPolicy may translate into. Each ACL +// maxACLsPerPolicy bounds generated ACL work on the full-NPM v2 ipset path. +// The Windows Lite direct-rule allocator is outside this work-budget guarantee; +// the final total check does not bound that allocator's intermediate work. Each ACL // becomes one iptables rule, and the count multiplies rather than adds: every flattened // namespaceSelector branch is emitted once per port in the rule, and that product is summed // across every peer and every rule in the policy. Bounding the flattened selector count on diff --git a/npm/pkg/dataplane/debug/converter_test.go b/npm/pkg/dataplane/debug/converter_test.go index 56d332b2904..12b8f131100 100644 --- a/npm/pkg/dataplane/debug/converter_test.go +++ b/npm/pkg/dataplane/debug/converter_test.go @@ -99,7 +99,7 @@ func TestGetProtobufRulesFromIptableFileV2(t *testing.T) { }, } - hitrules, _, _, err := getHitRules(srcPod, dstPod, rules, c.NPMCache) + hitrules, _, _, err := getHitRules(srcPod, dstPod, rules, c.NPMCache, c.EnableV2NPM) require.NoError(t, err) log.Printf("hitrules %+v", hitrules) if err != nil { diff --git a/npm/pkg/dataplane/debug/namespace_anchor_test.go b/npm/pkg/dataplane/debug/namespace_anchor_test.go index 53dccd78c88..4ee1c1ff099 100644 --- a/npm/pkg/dataplane/debug/namespace_anchor_test.go +++ b/npm/pkg/dataplane/debug/namespace_anchor_test.go @@ -27,7 +27,7 @@ func TestV2NamespaceAggregateMatch(t *testing.T) { Type: pb.SetType_KEYLABELOFNAMESPACE, Included: included, } - matched, err := evaluateSetInfo("src", set, &common.NpmPod{Namespace: namespace}, &pb.RuleResponse{}, cache) + matched, err := evaluateSetInfo("src", set, &common.NpmPod{Namespace: namespace}, &pb.RuleResponse{}, cache, true) require.NoError(t, err) require.Equal(t, included == (namespace == anchorPeerNamespace), matched) }) @@ -53,7 +53,7 @@ func TestV2NamespaceKeyOnlyMatch(t *testing.T) { set := &pb.RuleResponse_SetInfo{ Name: util.NamespaceLabelPrefix + "feature", Type: pb.SetType_KEYLABELOFNAMESPACE, Included: included, } - matched, err := evaluateSetInfo("src", set, &common.NpmPod{Namespace: anchorPeerNamespace}, &pb.RuleResponse{}, cache) + matched, err := evaluateSetInfo("src", set, &common.NpmPod{Namespace: anchorPeerNamespace}, &pb.RuleResponse{}, cache, true) require.NoError(t, err) require.Equal(t, included == test.present, matched) }) @@ -73,10 +73,10 @@ func TestV1NamespaceMatchingIsUnchanged(t *testing.T) { {Name: util.NamespacePrefix + "team:blue", Type: pb.SetType_KEYVALUELABELOFNAMESPACE, Included: true}, {Name: "nslabel-team:blue", Type: pb.SetType_KEYVALUELABELOFPOD, Included: true}, } { - matched, err := matchNamespaceAnchorConditions("src", pod, []*pb.RuleResponse_SetInfo{set}, &pb.RuleResponse{}, cache) + matched, err := matchNamespaceAnchorConditions("src", pod, []*pb.RuleResponse_SetInfo{set}, &pb.RuleResponse{}, cache, false) require.NoError(t, err) require.True(t, matched, "the v2 pre-check must leave v1 metadata alone") - matched, err = evaluateSetInfo("src", set, pod, &pb.RuleResponse{}, cache) + matched, err = evaluateSetInfo("src", set, pod, &pb.RuleResponse{}, cache, false) require.NoError(t, err) require.True(t, matched) } @@ -102,7 +102,7 @@ func TestV2MixedNamespaceConditionsAreConjunctive(t *testing.T) { }}} hits, _, _, err := getHitRules( &common.NpmPod{Namespace: anchorPeerNamespace}, &common.NpmPod{Namespace: anchorTargetNamespace}, - map[*pb.RuleResponse]struct{}{allow: {}, deny: {}}, cache, + map[*pb.RuleResponse]struct{}{allow: {}, deny: {}}, cache, true, ) require.NoError(t, err) want := []*pb.RuleResponse{deny} @@ -157,7 +157,7 @@ func TestNamespaceAnchorRulesRequireEveryNamespaceMatch(t *testing.T) { deny.SrcList = targetMatches } rules := map[*pb.RuleResponse]struct{}{allow: {}, deny: {}} - hits, _, _, err := getHitRules(src, dst, rules, cache) + hits, _, _, err := getHitRules(src, dst, rules, cache, true) require.NoError(t, err) want := []*pb.RuleResponse{deny} if tenant != "a" && tenant != "b" { @@ -166,7 +166,7 @@ func TestNamespaceAnchorRulesRequireEveryNamespaceMatch(t *testing.T) { require.ElementsMatch(t, want, hits) peer.Namespace = "" - hits, _, _, err = getHitRules(src, dst, rules, cache) + hits, _, _, err = getHitRules(src, dst, rules, cache, true) require.NoError(t, err) require.ElementsMatch(t, []*pb.RuleResponse{deny}, hits, "the aggregate must not match an external endpoint") }) @@ -194,7 +194,7 @@ func TestNamespaceAnchorConditionsDistinguishLabelPresence(t *testing.T) { {Name: util.NamespaceLabelPrefix + util.KubeAllNamespacesFlag, Type: pb.SetType_KEYLABELOFNAMESPACE}, {Name: util.NamespaceLabelPrefix + util.KubeAllNamespacesFlagV2, Type: pb.SetType_KEYLABELOFNAMESPACE, Included: true}, } - matched, err := matchNamespaceAnchorConditions("src", &common.NpmPod{Namespace: anchorPeerNamespace}, sets, &pb.RuleResponse{}, cache) + matched, err := matchNamespaceAnchorConditions("src", &common.NpmPod{Namespace: anchorPeerNamespace}, sets, &pb.RuleResponse{}, cache, true) require.NoError(t, err) require.Equal(t, test.want, matched) }) @@ -223,12 +223,12 @@ func TestNamespaceAnchorDoesNotOverridePodSelection(t *testing.T) { } deny := &pb.RuleResponse{DstList: []*pb.RuleResponse_SetInfo{targetSet}} rules := map[*pb.RuleResponse]struct{}{allow: {}, deny: {}} - hits, _, _, err := getHitRules(peer, target, rules, cache) + hits, _, _, err := getHitRules(peer, target, rules, cache, true) require.NoError(t, err) require.ElementsMatch(t, []*pb.RuleResponse{deny}, hits) peer.Labels["app"] = "required" - hits, _, _, err = getHitRules(peer, target, rules, cache) + hits, _, _, err = getHitRules(peer, target, rules, cache, true) require.NoError(t, err) require.ElementsMatch(t, []*pb.RuleResponse{allow, deny}, hits) } @@ -262,7 +262,7 @@ func TestNamespaceAnchorConditionsReportIncompleteSets(t *testing.T) { t.Run(test.name, func(t *testing.T) { matched, err := matchNamespaceAnchorConditions( "src", &common.NpmPod{Namespace: anchorPeerNamespace}, - []*pb.RuleResponse_SetInfo{anchor, test.set}, &pb.RuleResponse{}, cache, + []*pb.RuleResponse_SetInfo{anchor, test.set}, &pb.RuleResponse{}, cache, true, ) require.False(t, matched) require.ErrorIs(t, err, test.cause) diff --git a/npm/pkg/dataplane/debug/trafficanalyzer.go b/npm/pkg/dataplane/debug/trafficanalyzer.go index 5c6d4e3c081..2aeedee1d72 100644 --- a/npm/pkg/dataplane/debug/trafficanalyzer.go +++ b/npm/pkg/dataplane/debug/trafficanalyzer.go @@ -96,7 +96,7 @@ func (c *Converter) GetNetworkTuple(src, dst *common.Input, config *npmconfig.Co // after we have all rules from the AZURE-NPM chains in the filter table, get the network tuples of src and dst - return getNetworkTupleCommon(src, dst, c.NPMCache, allRules) + return getNetworkTupleCommon(src, dst, c.NPMCache, allRules, c.EnableV2NPM) } // GetNetworkTupleFile read from NPM cache and iptables-save files and @@ -112,7 +112,7 @@ func (c *Converter) GetNetworkTupleFile( //nolint:gocritic return nil, nil, nil, nil, fmt.Errorf("error occurred during get network tuple : %w", err) } - return getNetworkTupleCommon(src, dst, c.NPMCache, allRules) + return getNetworkTupleCommon(src, dst, c.NPMCache, allRules, c.EnableV2NPM) } // Common function. @@ -120,6 +120,7 @@ func getNetworkTupleCommon( src, dst *common.Input, npmCache common.GenericCache, allRules map[*pb.RuleResponse]struct{}, + enableV2NPM bool, ) ([][]byte, []*TupleAndRule, map[string]*pb.RuleResponse_SetInfo, map[string]*pb.RuleResponse_SetInfo, error) { srcPod, err := npmCache.GetPod(src) @@ -133,7 +134,7 @@ func getNetworkTupleCommon( } // find all rules where the source pod and dest pod exist - hitRules, srcSets, dstSets, err := getHitRules(srcPod, dstPod, allRules, npmCache) + hitRules, srcSets, dstSets, err := getHitRules(srcPod, dstPod, allRules, npmCache, enableV2NPM) if err != nil { return nil, nil, srcSets, dstSets, fmt.Errorf("%w", err) } @@ -220,6 +221,7 @@ func getHitRules( src, dst *common.NpmPod, rules map[*pb.RuleResponse]struct{}, npmCache common.GenericCache, + enableV2NPM bool, ) ([]*pb.RuleResponse, map[string]*pb.RuleResponse_SetInfo, map[string]*pb.RuleResponse_SetInfo, error) { res := make([]*pb.RuleResponse, 0) @@ -227,14 +229,14 @@ func getHitRules( dstSets := make(map[string]*pb.RuleResponse_SetInfo, 0) for rule := range rules { - srcNamespaceMatch, err := matchNamespaceAnchorConditions("src", src, rule.GetSrcList(), rule, npmCache) + srcNamespaceMatch, err := matchNamespaceAnchorConditions("src", src, rule.GetSrcList(), rule, npmCache, enableV2NPM) if err != nil { return nil, nil, nil, fmt.Errorf("evaluating source namespace conditions: %w", err) } if !srcNamespaceMatch { continue } - dstNamespaceMatch, err := matchNamespaceAnchorConditions("dst", dst, rule.GetDstList(), rule, npmCache) + dstNamespaceMatch, err := matchNamespaceAnchorConditions("dst", dst, rule.GetDstList(), rule, npmCache, enableV2NPM) if err != nil { return nil, nil, nil, fmt.Errorf("evaluating destination namespace conditions: %w", err) } @@ -250,7 +252,7 @@ func getHitRules( break } - matchedSource, err := evaluateSetInfo("src", setInfo, src, rule, npmCache) + matchedSource, err := evaluateSetInfo("src", setInfo, src, rule, npmCache, enableV2NPM) if err != nil { return nil, nil, nil, fmt.Errorf("error occurred during evaluating source's set info : %w", err) } @@ -268,7 +270,7 @@ func getHitRules( break } - matchedDestination, err := evaluateSetInfo("dst", setInfo, dst, rule, npmCache) + matchedDestination, err := evaluateSetInfo("dst", setInfo, dst, rule, npmCache, enableV2NPM) if err != nil { return nil, nil, nil, fmt.Errorf("error occurred during evaluating destination's set info : %w", err) } @@ -299,12 +301,15 @@ func getHitRules( } // V2 namespace conditions are conjunctive, whether or not an aggregate was needed. -// The namespace-label prefix and type keep legacy v1 matching outside this path. -func matchNamespaceAnchorConditions(origin string, pod *common.NpmPod, sets []*pb.RuleResponse_SetInfo, rule *pb.RuleResponse, npmCache common.GenericCache) (bool, error) { +// The converter's explicit mode keeps user-controlled v1 names outside this path. +func matchNamespaceAnchorConditions(origin string, pod *common.NpmPod, sets []*pb.RuleResponse_SetInfo, rule *pb.RuleResponse, npmCache common.GenericCache, enableV2NPM bool) (bool, error) { + if !enableV2NPM { + return true, nil + } hasV2Namespace := false for _, set := range sets { - if (set.GetType() == pb.SetType_KEYLABELOFNAMESPACE || set.GetType() == pb.SetType_KEYVALUELABELOFNAMESPACE) && - strings.HasPrefix(set.GetName(), util.NamespaceLabelPrefix) { + if set.GetType() == pb.SetType_NAMESPACE || + set.GetType() == pb.SetType_KEYLABELOFNAMESPACE || set.GetType() == pb.SetType_KEYVALUELABELOFNAMESPACE { hasV2Namespace = true break } @@ -342,15 +347,11 @@ func matchNamespaceAnchorConditions(origin string, pod *common.NpmPod, sets []*p } continue case pb.SetType_NESTEDLABELOFPOD: - // Current nested identities carry a policy/key, not their allowed values. - // Only older value-encoded names can be evaluated from this cache format. - if strings.Count(set.GetName(), util.IpsetLabelDelimter) < 2 { - return false, fmt.Errorf("missing nested label values for %q: %w", set.GetName(), common.ErrInvalidInput) - } - if !matchNESTEDLABELOFPOD(pod, set) { - return false, nil + var err error + matches, err = matchV2NestedLabelSet(pod.Labels, set.GetName()) + if err != nil { + return false, err } - continue case pb.SetType_CIDRBLOCKS, pb.SetType_UNKNOWN: return false, fmt.Errorf("unsupported anchored set %q: %w", set.GetName(), common.ErrSetType) default: @@ -373,6 +374,25 @@ func matchPrefixedLabelSet(labels map[string]string, setName, prefix string) (bo return exists && (!hasValue || actual == value), nil } +func matchV2NestedLabelSet(labels map[string]string, setName string) (bool, error) { + name, ok := strings.CutPrefix(setName, util.NestedLabelPrefix) + parts := strings.Split(name, util.IpsetLabelDelimter) + // V2 encodes policyKey:labelKey:value...; neither identity can contain ':'. + if !ok || len(parts) < 4 || parts[0] == "" || parts[1] == "" { + return false, fmt.Errorf("nested label set %q: %w", setName, common.ErrInvalidInput) + } + actual, exists := labels[parts[1]] + if !exists { + return false, nil + } + for _, expected := range parts[2:] { + if actual == expected { + return true, nil + } + } + return false, nil +} + // evalute an ipset to find out whether the pod's attributes match with the set func evaluateSetInfo( origin string, @@ -380,23 +400,45 @@ func evaluateSetInfo( pod *common.NpmPod, rule *pb.RuleResponse, npmCache common.GenericCache, + enableV2NPM bool, ) (bool, error) { switch setInfo.Type { case pb.SetType_KEYVALUELABELOFNAMESPACE: - if strings.HasPrefix(setInfo.GetName(), util.NamespaceLabelPrefix) { - return matchKEYLABELOFNAMESPACE(pod, npmCache, setInfo) + if enableV2NPM { + return matchKEYLABELOFNAMESPACE(pod, npmCache, setInfo, true) } return matchKEYVALUELABELOFNAMESPACE(pod, npmCache, setInfo), nil case pb.SetType_NESTEDLABELOFPOD: + if enableV2NPM { + matches, err := matchV2NestedLabelSet(pod.Labels, setInfo.GetName()) + if err != nil { + return false, err + } + return matches == setInfo.GetIncluded(), nil + } return matchNESTEDLABELOFPOD(pod, setInfo), nil case pb.SetType_KEYLABELOFNAMESPACE: - return matchKEYLABELOFNAMESPACE(pod, npmCache, setInfo) + return matchKEYLABELOFNAMESPACE(pod, npmCache, setInfo, enableV2NPM) case pb.SetType_NAMESPACE: return matchNAMESPACE(pod, setInfo), nil case pb.SetType_KEYVALUELABELOFPOD: + if enableV2NPM { + matches, err := matchPrefixedLabelSet(pod.Labels, setInfo.GetName(), util.PodLabelPrefix) + if err != nil { + return false, err + } + return matches == setInfo.GetIncluded(), nil + } return matchKEYVALUELABELOFPOD(pod, setInfo), nil case pb.SetType_KEYLABELOFPOD: + if enableV2NPM { + matches, err := matchPrefixedLabelSet(pod.Labels, setInfo.GetName(), util.PodLabelPrefix) + if err != nil { + return false, err + } + return matches == setInfo.GetIncluded(), nil + } return matchKEYLABELOFPOD(pod, setInfo), nil case pb.SetType_NAMEDPORTS: return matchNAMEDPORTS(pod, setInfo, rule, origin), nil @@ -448,12 +490,12 @@ func matchNESTEDLABELOFPOD(pod *common.NpmPod, setInfo *pb.RuleResponse_SetInfo) return true } -func matchKEYLABELOFNAMESPACE(pod *common.NpmPod, npmCache common.GenericCache, setInfo *pb.RuleResponse_SetInfo) (bool, error) { - if setInfo.GetName() == util.NamespaceLabelPrefix+util.KubeAllNamespacesFlagV2 { +func matchKEYLABELOFNAMESPACE(pod *common.NpmPod, npmCache common.GenericCache, setInfo *pb.RuleResponse_SetInfo, enableV2NPM bool) (bool, error) { + if enableV2NPM && setInfo.GetName() == util.NamespaceLabelPrefix+util.KubeAllNamespacesFlagV2 { _, namespaceExists := npmCache.GetNamespaceLabels(pod.Namespace) return setInfo.GetIncluded() == (pod.Namespace != "" && namespaceExists), nil } - if strings.HasPrefix(setInfo.GetName(), util.NamespaceLabelPrefix) { + if enableV2NPM { labels, _ := npmCache.GetNamespaceLabels(pod.Namespace) matches, err := matchPrefixedLabelSet(labels, setInfo.GetName(), util.NamespaceLabelPrefix) if err != nil { diff --git a/npm/pkg/dataplane/debug/trafficanalyzer_test.go b/npm/pkg/dataplane/debug/trafficanalyzer_test.go index 909b1c126be..e0d10ec41c6 100644 --- a/npm/pkg/dataplane/debug/trafficanalyzer_test.go +++ b/npm/pkg/dataplane/debug/trafficanalyzer_test.go @@ -50,6 +50,7 @@ func TestGetInputType(t *testing.T) { } func TestGetNetworkTuple(t *testing.T) { + const selectedPodIP = "10.224.0.70" type srcDstPair struct { src *common.Input dst *common.Input @@ -61,7 +62,7 @@ func TestGetNetworkTuple(t *testing.T) { } i0 := &srcDstPair{ - src: &common.Input{Content: "y/b", Type: common.NSPODNAME}, + src: &common.Input{Content: "y/a", Type: common.NSPODNAME}, dst: &common.Input{Content: "x/b", Type: common.NSPODNAME}, } @@ -71,7 +72,7 @@ func TestGetNetworkTuple(t *testing.T) { { RuleType: "ALLOWED", Direction: "EGRESS", - SrcIP: "10.224.0.17", + SrcIP: selectedPodIP, SrcPort: "ANY", DstIP: "ANY", DstPort: "53", @@ -80,7 +81,7 @@ func TestGetNetworkTuple(t *testing.T) { { RuleType: "ALLOWED", Direction: "EGRESS", - SrcIP: "10.224.0.17", + SrcIP: selectedPodIP, SrcPort: "ANY", DstIP: "ANY", DstPort: "53", @@ -89,7 +90,7 @@ func TestGetNetworkTuple(t *testing.T) { { RuleType: "NOT ALLOWED", Direction: "EGRESS", - SrcIP: "10.224.0.17", + SrcIP: selectedPodIP, SrcPort: "ANY", DstIP: "ANY", DstPort: "ANY", diff --git a/npm/pkg/dataplane/debug/version_mode_test.go b/npm/pkg/dataplane/debug/version_mode_test.go new file mode 100644 index 00000000000..d548ec8251f --- /dev/null +++ b/npm/pkg/dataplane/debug/version_mode_test.go @@ -0,0 +1,96 @@ +package debug + +import ( + "testing" + + common "github.com/Azure/azure-container-networking/npm/pkg/controlplane/controllers/common" + "github.com/Azure/azure-container-networking/npm/pkg/controlplane/translation" + "github.com/Azure/azure-container-networking/npm/pkg/dataplane/ipsets" + "github.com/Azure/azure-container-networking/npm/pkg/dataplane/pb" + "github.com/Azure/azure-container-networking/npm/util" + "github.com/stretchr/testify/require" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestV1NamespacePrefixDoesNotSelectV2(t *testing.T) { + const labelKey = "nslabel-team" + cache := &common.Cache{NsMap: map[string]*common.Namespace{ + anchorPeerNamespace: {LabelsMap: map[string]string{labelKey: matchedTeamValue}}, + }} + set := &pb.RuleResponse_SetInfo{ + Name: labelKey, Type: pb.SetType_KEYLABELOFNAMESPACE, Included: true, + } + matched, err := matchNamespaceAnchorConditions( + "src", &common.NpmPod{Namespace: anchorPeerNamespace}, + []*pb.RuleResponse_SetInfo{set}, &pb.RuleResponse{}, cache, false, + ) + require.NoError(t, err) + require.True(t, matched, "v1 user-controlled names must not enable the v2 pre-check") +} + +func TestV2PodOnlyPeerRequiresNamespace(t *testing.T) { + allow := &pb.RuleResponse{ + Allowed: true, + SrcList: []*pb.RuleResponse_SetInfo{ + {Name: util.PodLabelPrefix + "app:shared", Type: pb.SetType_KEYLABELOFPOD, Included: true}, + {Name: util.NamespacePrefix + anchorPeerNamespace, Type: pb.SetType_NAMESPACE, Included: true}, + }, + } + target := &common.NpmPod{Namespace: anchorTargetNamespace} + deny := &pb.RuleResponse{DstList: []*pb.RuleResponse_SetInfo{{ + Name: util.NamespacePrefix + anchorTargetNamespace, Type: pb.SetType_NAMESPACE, Included: true, + }}} + rules := map[*pb.RuleResponse]struct{}{allow: {}, deny: {}} + for _, namespace := range []string{anchorPeerNamespace, "different"} { + peer := &common.NpmPod{Namespace: namespace, Labels: map[string]string{"app": "shared"}} + hits, _, _, err := getHitRules(peer, target, rules, &common.Cache{}, true) + require.NoError(t, err) + want := []*pb.RuleResponse{deny} + if namespace == anchorPeerNamespace { + want = append(want, allow) + } + require.ElementsMatch(t, want, hits) + } +} + +func TestV2NestedSelectorUsesTranslatedLabelKey(t *testing.T) { + const labelKey = "app" + policy := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "nested", Namespace: anchorPeerNamespace}, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{{ + Key: labelKey, Operator: metav1.LabelSelectorOpIn, Values: []string{"one", "two"}, + }}}, + PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress}, + Ingress: []networkingv1.NetworkPolicyIngressRule{{}}, + }, + } + translated, err := translation.TranslatePolicy(policy, false) + require.NoError(t, err) + var nestedName string + for _, set := range translated.PodSelectorIPSets { + if set.Metadata.Type == ipsets.NestedLabelOfPod { + nestedName = util.NestedLabelPrefix + set.Metadata.Name + } + } + require.NotEmpty(t, nestedName) + for _, test := range []struct { + name string + labels map[string]string + want bool + }{ + {"first value", map[string]string{labelKey: "one"}, true}, + {"second value", map[string]string{labelKey: "two"}, true}, + {"different value", map[string]string{labelKey: otherLabelValue}, false}, + {"missing key", nil, false}, + {"policy identity is not a key", map[string]string{translated.PolicyKey: labelKey}, false}, + } { + t.Run(test.name, func(t *testing.T) { + set := &pb.RuleResponse_SetInfo{Name: nestedName, Type: pb.SetType_NESTEDLABELOFPOD, Included: true} + matched, err := evaluateSetInfo("src", set, &common.NpmPod{Labels: test.labels}, &pb.RuleResponse{}, &common.Cache{}, true) + require.NoError(t, err) + require.Equal(t, test.want, matched) + }) + } +} diff --git a/npm/pkg/dataplane/testdata/npmcachev2.json b/npm/pkg/dataplane/testdata/npmcachev2.json index f65b803020c..5e998817b80 100644 --- a/npm/pkg/dataplane/testdata/npmcachev2.json +++ b/npm/pkg/dataplane/testdata/npmcachev2.json @@ -459,7 +459,7 @@ "azure-npm-2540899149": "podlabel-k8s-app", "azure-npm-2547206700": "podlabel-pod-template-hash:774f99dbf4", "azure-npm-2647803239": "nslabel-control-plane:true", - "azure-npm-2682470511": "nestedlabel-pod:a:b", + "azure-npm-2682470511": "nestedlabel-y/test-policy:pod:a:b", "azure-npm-2714724634": "podlabel-k8s-app:kube-dns", "azure-npm-2764516068": "nslabel-addonmanager.kubernetes.io/mode", "azure-npm-2837910840": "ns-y", @@ -486,7 +486,7 @@ "azure-npm-4272224941": "podlabel-app:konnectivity-agent", "azure-npm-4284971813": "namedport:serve-80-tcp", "azure-npm-483924252": "nslabel-ns", - "azure-npm-55798953": "nestedlabel-pod:b:c", + "azure-npm-55798953": "nestedlabel-y/test-policy:pod:b:c", "azure-npm-708060905": "podlabel-version:v20", "azure-npm-71974944": "namedport:dns", "azure-npm-784554818": "ns-default", From 10ec9b66137b7945202803f53b01d4a24a7a9736 Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Tue, 15 Sep 2026 00:54:34 +0000 Subject: [PATCH 25/33] fix: [NPM] reject invalid exclusions before set programming Reject all-addresses exceptions that cannot be strict CIDR subsets, keep v2 pod and port conditions conjunctive without namespace matches, and synchronize nested fixture identities with their generated kernel hashes and rule comments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 175c523d-ad1c-4e31-986d-7c9940c6c18c --- .../translation/cidr_errors_test.go | 4 +++ .../translation/translatePolicy.go | 6 ++++ .../debug/fixture_consistency_test.go | 33 +++++++++++++++++++ npm/pkg/dataplane/debug/trafficanalyzer.go | 11 +++---- npm/pkg/dataplane/debug/version_mode_test.go | 20 +++++++++++ npm/pkg/dataplane/testdata/iptablesave-v2 | 8 ++--- npm/pkg/dataplane/testdata/npmcachev2.json | 4 +-- 7 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 npm/pkg/dataplane/debug/fixture_consistency_test.go diff --git a/npm/pkg/controlplane/translation/cidr_errors_test.go b/npm/pkg/controlplane/translation/cidr_errors_test.go index df74470bb0e..21867b2d23b 100644 --- a/npm/pkg/controlplane/translation/cidr_errors_test.go +++ b/npm/pkg/controlplane/translation/cidr_errors_test.go @@ -14,6 +14,8 @@ func TestIPBlockNormalizationErrorCauses(t *testing.T) { const ( ipv6CIDR = "2001:db8:2::/48" malformedCIDR = "invalid" + allAddresses = "0.0.0.0/0" + hostBitsZero = "10.0.0.0/0" ) for _, test := range []struct { name string @@ -30,6 +32,8 @@ func TestIPBlockNormalizationErrorCauses(t *testing.T) { {"invalid parent and exclusion", networkingv1.IPBlock{CIDR: malformedCIDR, Except: []string{malformedCIDR}}, util.ErrInvalidCIDR, false}, {"invalid exclusion", networkingv1.IPBlock{CIDR: enclosingCIDR, Except: []string{malformedCIDR}}, util.ErrInvalidCIDR, true}, {"IPv6 exclusion", networkingv1.IPBlock{CIDR: enclosingCIDR, Except: []string{ipv6CIDR}}, util.ErrUnsupportedIPFamily, true}, + {"all-addresses exclusion", networkingv1.IPBlock{CIDR: allAddresses, Except: []string{allAddresses}}, ErrInvalidIPBlockExcept, true}, + {"noncanonical all-addresses exclusion", networkingv1.IPBlock{CIDR: hostBitsZero, Except: []string{hostBitsZero}}, ErrInvalidIPBlockExcept, true}, } { t.Run(test.name, func(t *testing.T) { unsupportedExcept := util.IsWindowsDP() && test.windowsExceptFailure diff --git a/npm/pkg/controlplane/translation/translatePolicy.go b/npm/pkg/controlplane/translation/translatePolicy.go index da8c2f1d2a4..406ee7f765c 100644 --- a/npm/pkg/controlplane/translation/translatePolicy.go +++ b/npm/pkg/controlplane/translation/translatePolicy.go @@ -58,6 +58,8 @@ var ( ErrTooManyACLs = errors.New("network policy expands into too many rules") // ErrUnsupportedIPAddress is returned when an unsupported IP address, such as IPV6, is used ErrUnsupportedIPAddress = errors.New("unsupported IP address") + // ErrInvalidIPBlockExcept is returned for an exclusion that cannot be a strict subset. + ErrInvalidIPBlockExcept = errors.New("ipBlock except must be a strict subset of its CIDR") // ErrUnsupportedNonCIDR is returned when non-CIDR blocks are passed in with NPM Lite enabled. NPM Lite allows deny-all and allow-all policies ErrUnsupportedNonCIDR = errors.New("Non-CIDR blocks, named ports, and ingress/egress namespace/pod selectors are not supported when NPM Lite is enabled, allowing only CIDR-based policies") ) @@ -204,6 +206,10 @@ func canonicalizeExcepts(exceptInIPBlock []string) ([]string, error) { if err != nil { return nil, fmt.Errorf("except %q: %w: %w", except, ErrUnsupportedIPAddress, err) } + // An all-addresses exclusion cannot be a strict subset of any IPv4 CIDR. + if canonical == "0.0.0.0/0" { + return nil, fmt.Errorf("except %q: %w: %w", except, ErrUnsupportedIPAddress, ErrInvalidIPBlockExcept) + } if _, exist := exceptsSet[canonical]; !exist { canonicalExcepts = append(canonicalExcepts, canonical) exceptsSet[canonical] = struct{}{} diff --git a/npm/pkg/dataplane/debug/fixture_consistency_test.go b/npm/pkg/dataplane/debug/fixture_consistency_test.go new file mode 100644 index 00000000000..976103ae395 --- /dev/null +++ b/npm/pkg/dataplane/debug/fixture_consistency_test.go @@ -0,0 +1,33 @@ +package debug + +import ( + "os" + "strings" + "testing" + + "github.com/Azure/azure-container-networking/npm/pkg/dataplane/ipsets" + "github.com/Azure/azure-container-networking/npm/util" + "github.com/stretchr/testify/require" +) + +func TestV2NestedFixtureIdentitiesMatchKernelNames(t *testing.T) { + converter := &Converter{EnableV2NPM: true} + require.NoError(t, converter.NpmCacheFromFile(npmCacheFileV2)) + rules, err := os.ReadFile(iptableSaveFileV2) + require.NoError(t, err) + count := 0 + for hashedName, name := range converter.NPMCache.GetSetMap() { + unprefixedName, nested := strings.CutPrefix(name, util.NestedLabelPrefix) + if !nested { + continue + } + count++ + t.Run(name, func(t *testing.T) { + metadata := ipsets.NewIPSetMetadata(unprefixedName, ipsets.NestedLabelOfPod) + require.Equal(t, metadata.GetHashedName(), hashedName) + require.Contains(t, string(rules), hashedName) + require.Contains(t, string(rules), name) + }) + } + require.Equal(t, 2, count) +} diff --git a/npm/pkg/dataplane/debug/trafficanalyzer.go b/npm/pkg/dataplane/debug/trafficanalyzer.go index 2aeedee1d72..1d099f4499f 100644 --- a/npm/pkg/dataplane/debug/trafficanalyzer.go +++ b/npm/pkg/dataplane/debug/trafficanalyzer.go @@ -300,21 +300,20 @@ func getHitRules( return res, srcSets, dstSets, nil } -// V2 namespace conditions are conjunctive, whether or not an aggregate was needed. +// V2 selector conditions are conjunctive, whether or not an aggregate was needed. // The converter's explicit mode keeps user-controlled v1 names outside this path. func matchNamespaceAnchorConditions(origin string, pod *common.NpmPod, sets []*pb.RuleResponse_SetInfo, rule *pb.RuleResponse, npmCache common.GenericCache, enableV2NPM bool) (bool, error) { if !enableV2NPM { return true, nil } - hasV2Namespace := false + hasSelectorCondition := false for _, set := range sets { - if set.GetType() == pb.SetType_NAMESPACE || - set.GetType() == pb.SetType_KEYLABELOFNAMESPACE || set.GetType() == pb.SetType_KEYVALUELABELOFNAMESPACE { - hasV2Namespace = true + if set.GetType() != pb.SetType_CIDRBLOCKS && set.GetType() != pb.SetType_UNKNOWN { + hasSelectorCondition = true break } } - if !hasV2Namespace { + if !hasSelectorCondition { return true, nil } diff --git a/npm/pkg/dataplane/debug/version_mode_test.go b/npm/pkg/dataplane/debug/version_mode_test.go index d548ec8251f..713cddff73c 100644 --- a/npm/pkg/dataplane/debug/version_mode_test.go +++ b/npm/pkg/dataplane/debug/version_mode_test.go @@ -54,6 +54,26 @@ func TestV2PodOnlyPeerRequiresNamespace(t *testing.T) { } } +func TestV2PodConditionsWithoutNamespaceAreConjunctive(t *testing.T) { + peer := &common.NpmPod{Namespace: anchorPeerNamespace, Labels: map[string]string{"app": "shared", "role": otherLabelValue}} + allow := &pb.RuleResponse{Allowed: true, SrcList: []*pb.RuleResponse_SetInfo{ + {Name: util.PodLabelPrefix + "app:shared", Type: pb.SetType_KEYLABELOFPOD, Included: true}, + {Name: util.PodLabelPrefix + "role:required", Type: pb.SetType_KEYLABELOFPOD, Included: true}, + }} + deny := &pb.RuleResponse{DstList: []*pb.RuleResponse_SetInfo{{ + Name: util.NamespacePrefix + anchorTargetNamespace, Type: pb.SetType_NAMESPACE, Included: true, + }}} + target := &common.NpmPod{Namespace: anchorTargetNamespace} + rules := map[*pb.RuleResponse]struct{}{allow: {}, deny: {}} + hits, _, _, err := getHitRules(peer, target, rules, &common.Cache{}, true) + require.NoError(t, err) + require.ElementsMatch(t, []*pb.RuleResponse{deny}, hits) + peer.Labels["role"] = "required" + hits, _, _, err = getHitRules(peer, target, rules, &common.Cache{}, true) + require.NoError(t, err) + require.ElementsMatch(t, []*pb.RuleResponse{allow, deny}, hits) +} + func TestV2NestedSelectorUsesTranslatedLabelKey(t *testing.T) { const labelKey = "app" policy := &networkingv1.NetworkPolicy{ diff --git a/npm/pkg/dataplane/testdata/iptablesave-v2 b/npm/pkg/dataplane/testdata/iptablesave-v2 index a4e2768b735..19487ecfaee 100644 --- a/npm/pkg/dataplane/testdata/iptablesave-v2 +++ b/npm/pkg/dataplane/testdata/iptablesave-v2 @@ -375,8 +375,8 @@ COMMIT -A AZURE-NPM-EGRESS -m set --match-set azure-npm-4272224941 src -m set --match-set azure-npm-2064349730 src -m comment --comment "EGRESS-POLICY-kube-system/konnectivity-agent-FROM-podlabel-app:konnectivity-agent-AND-ns-kube-system-IN-ns-kube-system" -j AZURE-NPM-EGRESS-3618314628 -A AZURE-NPM-EGRESS -m mark --mark 0x5000 -m comment --comment DROP-ON-EGRESS-DROP-MARK-0x5000 -j DROP -A AZURE-NPM-EGRESS -m mark --mark 0x2000 -m comment --comment ACCEPT-ON-INGRESS-ALLOW-MARK-0x2000 -j AZURE-NPM-ACCEPT --A AZURE-NPM-EGRESS-2697641196 -p tcp -m tcp --dport 80 -m set --match-set azure-npm-2146053937 dst -m set --match-set azure-npm-2682470511 dst -m comment --comment "ALLOW-TO-nslabel-ns:y-AND-nestedlabel-pod:a:b-ON-TCP-TO-PORT-80" -j AZURE-NPM-ACCEPT --A AZURE-NPM-EGRESS-2697641196 -p tcp -m tcp --dport 80 -m set --match-set azure-npm-2095721080 dst -m set --match-set azure-npm-2682470511 dst -m comment --comment "ALLOW-TO-nslabel-ns:z-AND-nestedlabel-pod:a:b-ON-TCP-TO-PORT-80" -j AZURE-NPM-ACCEPT +-A AZURE-NPM-EGRESS-2697641196 -p tcp -m tcp --dport 80 -m set --match-set azure-npm-2146053937 dst -m set --match-set azure-npm-5p1pifvksz7fldlldq08 dst -m comment --comment "ALLOW-TO-nslabel-ns:y-AND-nestedlabel-y/test-policy:pod:a:b-ON-TCP-TO-PORT-80" -j AZURE-NPM-ACCEPT +-A AZURE-NPM-EGRESS-2697641196 -p tcp -m tcp --dport 80 -m set --match-set azure-npm-2095721080 dst -m set --match-set azure-npm-5p1pifvksz7fldlldq08 dst -m comment --comment "ALLOW-TO-nslabel-ns:z-AND-nestedlabel-y/test-policy:pod:a:b-ON-TCP-TO-PORT-80" -j AZURE-NPM-ACCEPT -A AZURE-NPM-EGRESS-2697641196 -p udp -m udp --dport 53 -m comment --comment ALLOW-ALL-ON-UDP-TO-PORT-53 -j AZURE-NPM-ACCEPT -A AZURE-NPM-EGRESS-2697641196 -p tcp -m tcp --dport 53 -m comment --comment ALLOW-ALL-ON-TCP-TO-PORT-53 -j AZURE-NPM-ACCEPT -A AZURE-NPM-EGRESS-2697641196 -m comment --comment DROP-ALL -j MARK --set-xmark 0x5000/0xffffffff @@ -384,8 +384,8 @@ COMMIT -A AZURE-NPM-INGRESS -m set --match-set azure-npm-2064349730 dst -m comment --comment "INGRESS-POLICY-kube-system/default-deny-ingress-TO-ns-kube-system-IN-ns-kube-system" -j AZURE-NPM-INGRESS-3750705395 -A AZURE-NPM-INGRESS -m set --match-set azure-npm-3922407721 dst -m set --match-set azure-npm-2837910840 dst -m comment --comment "INGRESS-POLICY-y/base-TO-podlabel-pod:a-AND-ns-y-IN-ns-y" -j AZURE-NPM-INGRESS-2697641196 -A AZURE-NPM-INGRESS -m mark --mark 0x4000 -m comment --comment DROP-ON-INGRESS-DROP-MARK-0x4000 -j DROP --A AZURE-NPM-INGRESS-2697641196 -p tcp -m tcp --dport 80 -m set --match-set azure-npm-2129276318 src -m set --match-set azure-npm-55798953 src -m comment --comment "ALLOW-FROM-nslabel-ns:x-AND-nestedlabel-pod:b:c-ON-TCP-TO-PORT-80" -j AZURE-NPM-INGRESS-ALLOW-MARK --A AZURE-NPM-INGRESS-2697641196 -p tcp -m tcp --dport 80 -m set --match-set azure-npm-2146053937 src -m set --match-set azure-npm-55798953 src -m comment --comment "ALLOW-FROM-nslabel-ns:y-AND-nestedlabel-pod:b:c-ON-TCP-TO-PORT-80" -j AZURE-NPM-INGRESS-ALLOW-MARK +-A AZURE-NPM-INGRESS-2697641196 -p tcp -m tcp --dport 80 -m set --match-set azure-npm-2129276318 src -m set --match-set azure-npm-67n0hp4c1th5oiesg5w0 src -m comment --comment "ALLOW-FROM-nslabel-ns:x-AND-nestedlabel-y/test-policy:pod:b:c-ON-TCP-TO-PORT-80" -j AZURE-NPM-INGRESS-ALLOW-MARK +-A AZURE-NPM-INGRESS-2697641196 -p tcp -m tcp --dport 80 -m set --match-set azure-npm-2146053937 src -m set --match-set azure-npm-67n0hp4c1th5oiesg5w0 src -m comment --comment "ALLOW-FROM-nslabel-ns:y-AND-nestedlabel-y/test-policy:pod:b:c-ON-TCP-TO-PORT-80" -j AZURE-NPM-INGRESS-ALLOW-MARK -A AZURE-NPM-INGRESS-2697641196 -m comment --comment DROP-ALL -j MARK --set-xmark 0x4000/0xffffffff -A AZURE-NPM-INGRESS-3750705395 -m comment --comment DROP-ALL -j MARK --set-xmark 0x4000/0xffffffff -A AZURE-NPM-INGRESS-ALLOW-MARK -m comment --comment SET-INGRESS-ALLOW-MARK-0x2000 -j MARK --set-xmark 0x2000/0xffffffff diff --git a/npm/pkg/dataplane/testdata/npmcachev2.json b/npm/pkg/dataplane/testdata/npmcachev2.json index 5e998817b80..8cd28343caf 100644 --- a/npm/pkg/dataplane/testdata/npmcachev2.json +++ b/npm/pkg/dataplane/testdata/npmcachev2.json @@ -459,7 +459,7 @@ "azure-npm-2540899149": "podlabel-k8s-app", "azure-npm-2547206700": "podlabel-pod-template-hash:774f99dbf4", "azure-npm-2647803239": "nslabel-control-plane:true", - "azure-npm-2682470511": "nestedlabel-y/test-policy:pod:a:b", + "azure-npm-5p1pifvksz7fldlldq08": "nestedlabel-y/test-policy:pod:a:b", "azure-npm-2714724634": "podlabel-k8s-app:kube-dns", "azure-npm-2764516068": "nslabel-addonmanager.kubernetes.io/mode", "azure-npm-2837910840": "ns-y", @@ -486,7 +486,7 @@ "azure-npm-4272224941": "podlabel-app:konnectivity-agent", "azure-npm-4284971813": "namedport:serve-80-tcp", "azure-npm-483924252": "nslabel-ns", - "azure-npm-55798953": "nestedlabel-y/test-policy:pod:b:c", + "azure-npm-67n0hp4c1th5oiesg5w0": "nestedlabel-y/test-policy:pod:b:c", "azure-npm-708060905": "podlabel-version:v20", "azure-npm-71974944": "namedport:dns", "azure-npm-784554818": "ns-default", From 103ef3ec06ea64a21012d072dccf7dee0cd2bc50 Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Tue, 15 Sep 2026 01:08:54 +0000 Subject: [PATCH 26/33] test: [NPM] share diagnostic label constants Use a package-level label key across the diagnostic regressions so the full PR lint range stays consistent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 175c523d-ad1c-4e31-986d-7c9940c6c18c --- npm/pkg/dataplane/debug/namespace_anchor_test.go | 5 +++-- npm/pkg/dataplane/debug/version_mode_test.go | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/npm/pkg/dataplane/debug/namespace_anchor_test.go b/npm/pkg/dataplane/debug/namespace_anchor_test.go index 4ee1c1ff099..7e11f5d35bf 100644 --- a/npm/pkg/dataplane/debug/namespace_anchor_test.go +++ b/npm/pkg/dataplane/debug/namespace_anchor_test.go @@ -15,6 +15,7 @@ const ( anchorTargetNamespace = "target" matchedTeamValue = "blue" otherLabelValue = "other" + diagnosticAppLabelKey = "app" ) func TestV2NamespaceAggregateMatch(t *testing.T) { @@ -203,7 +204,7 @@ func TestNamespaceAnchorConditionsDistinguishLabelPresence(t *testing.T) { func TestNamespaceAnchorDoesNotOverridePodSelection(t *testing.T) { cache := &common.Cache{NsMap: map[string]*common.Namespace{anchorPeerNamespace: {}}} - peer := &common.NpmPod{Namespace: anchorPeerNamespace, Labels: map[string]string{"app": otherLabelValue}} + peer := &common.NpmPod{Namespace: anchorPeerNamespace, Labels: map[string]string{diagnosticAppLabelKey: otherLabelValue}} target := &common.NpmPod{Namespace: anchorTargetNamespace} converter := &Converter{EnableV2NPM: true} podSet := &pb.RuleResponse_SetInfo{ @@ -227,7 +228,7 @@ func TestNamespaceAnchorDoesNotOverridePodSelection(t *testing.T) { require.NoError(t, err) require.ElementsMatch(t, []*pb.RuleResponse{deny}, hits) - peer.Labels["app"] = "required" + peer.Labels[diagnosticAppLabelKey] = "required" hits, _, _, err = getHitRules(peer, target, rules, cache, true) require.NoError(t, err) require.ElementsMatch(t, []*pb.RuleResponse{allow, deny}, hits) diff --git a/npm/pkg/dataplane/debug/version_mode_test.go b/npm/pkg/dataplane/debug/version_mode_test.go index 713cddff73c..ee9d1b8166e 100644 --- a/npm/pkg/dataplane/debug/version_mode_test.go +++ b/npm/pkg/dataplane/debug/version_mode_test.go @@ -43,7 +43,7 @@ func TestV2PodOnlyPeerRequiresNamespace(t *testing.T) { }}} rules := map[*pb.RuleResponse]struct{}{allow: {}, deny: {}} for _, namespace := range []string{anchorPeerNamespace, "different"} { - peer := &common.NpmPod{Namespace: namespace, Labels: map[string]string{"app": "shared"}} + peer := &common.NpmPod{Namespace: namespace, Labels: map[string]string{diagnosticAppLabelKey: "shared"}} hits, _, _, err := getHitRules(peer, target, rules, &common.Cache{}, true) require.NoError(t, err) want := []*pb.RuleResponse{deny} @@ -55,7 +55,7 @@ func TestV2PodOnlyPeerRequiresNamespace(t *testing.T) { } func TestV2PodConditionsWithoutNamespaceAreConjunctive(t *testing.T) { - peer := &common.NpmPod{Namespace: anchorPeerNamespace, Labels: map[string]string{"app": "shared", "role": otherLabelValue}} + peer := &common.NpmPod{Namespace: anchorPeerNamespace, Labels: map[string]string{diagnosticAppLabelKey: "shared", "role": otherLabelValue}} allow := &pb.RuleResponse{Allowed: true, SrcList: []*pb.RuleResponse_SetInfo{ {Name: util.PodLabelPrefix + "app:shared", Type: pb.SetType_KEYLABELOFPOD, Included: true}, {Name: util.PodLabelPrefix + "role:required", Type: pb.SetType_KEYLABELOFPOD, Included: true}, @@ -75,7 +75,7 @@ func TestV2PodConditionsWithoutNamespaceAreConjunctive(t *testing.T) { } func TestV2NestedSelectorUsesTranslatedLabelKey(t *testing.T) { - const labelKey = "app" + const labelKey = diagnosticAppLabelKey policy := &networkingv1.NetworkPolicy{ ObjectMeta: metav1.ObjectMeta{Name: "nested", Namespace: anchorPeerNamespace}, Spec: networkingv1.NetworkPolicySpec{ From c645df892cd3762794b39c35f9cb7fb9ad5fd5fe Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Tue, 15 Sep 2026 01:27:57 +0000 Subject: [PATCH 27/33] fix: [NPM] preserve invalid-exclusion error classification Keep typed invalid exclusions distinct from platform limitations in every classifier mode and update the aggregate cache fixture to its current identity and generated hash. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 175c523d-ad1c-4e31-986d-7c9940c6c18c --- .../controllers/v2/networkPolicyController.go | 2 +- .../controllers/v2/networkPolicyController_test.go | 2 ++ npm/pkg/dataplane/debug/fixture_consistency_test.go | 8 ++++++++ npm/pkg/dataplane/testdata/npmcachev2.json | 2 +- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/npm/pkg/controlplane/controllers/v2/networkPolicyController.go b/npm/pkg/controlplane/controllers/v2/networkPolicyController.go index 09ab776173f..520a1553c0b 100644 --- a/npm/pkg/controlplane/controllers/v2/networkPolicyController.go +++ b/npm/pkg/controlplane/controllers/v2/networkPolicyController.go @@ -374,7 +374,7 @@ func isUnsupportedWindowsTranslationErr(err error) bool { // or mode NPM is running in, rather than a policy NPM failed to translate. Those limitations // stay suppressed with a warning; other failures must be reported without recording success. func isUnsupportedTranslationErr(err error, npmLiteToggle bool) bool { - if errors.Is(err, util.ErrInvalidCIDR) { + if errors.Is(err, util.ErrInvalidCIDR) || errors.Is(err, translation.ErrInvalidIPBlockExcept) { return false } // Full NPM supplies a typed cause; only Lite retains unclassified address errors. diff --git a/npm/pkg/controlplane/controllers/v2/networkPolicyController_test.go b/npm/pkg/controlplane/controllers/v2/networkPolicyController_test.go index 54b7b072550..14760119170 100644 --- a/npm/pkg/controlplane/controllers/v2/networkPolicyController_test.go +++ b/npm/pkg/controlplane/controllers/v2/networkPolicyController_test.go @@ -736,6 +736,8 @@ func TestUnsupportedAddressClassificationIsPlatformSpecific(t *testing.T) { {"Lite unsupported family", fmt.Errorf("%w: %w", translation.ErrUnsupportedIPAddress, util.ErrUnsupportedIPFamily), true, util.IsWindowsDP()}, {"full malformed CIDR", fmt.Errorf("%w: %w", translation.ErrUnsupportedIPAddress, util.ErrInvalidCIDR), false, false}, {"Lite typed malformed CIDR", fmt.Errorf("%w: %w", translation.ErrUnsupportedIPAddress, util.ErrInvalidCIDR), true, false}, + {"full invalid exclusion", fmt.Errorf("%w: %w", translation.ErrUnsupportedIPAddress, translation.ErrInvalidIPBlockExcept), false, false}, + {"Lite typed invalid exclusion", fmt.Errorf("%w: %w", translation.ErrUnsupportedIPAddress, translation.ErrInvalidIPBlockExcept), true, false}, } { t.Run(test.name, func(t *testing.T) { require.Equal(t, test.want, isUnsupportedTranslationErr(test.err, test.npmLite)) diff --git a/npm/pkg/dataplane/debug/fixture_consistency_test.go b/npm/pkg/dataplane/debug/fixture_consistency_test.go index 976103ae395..5112af7d897 100644 --- a/npm/pkg/dataplane/debug/fixture_consistency_test.go +++ b/npm/pkg/dataplane/debug/fixture_consistency_test.go @@ -31,3 +31,11 @@ func TestV2NestedFixtureIdentitiesMatchKernelNames(t *testing.T) { } require.Equal(t, 2, count) } + +func TestV2AggregateFixtureUsesCurrentIdentity(t *testing.T) { + converter := &Converter{EnableV2NPM: true} + require.NoError(t, converter.NpmCacheFromFile(npmCacheFileV2)) + metadata := ipsets.NewIPSetMetadata(util.KubeAllNamespacesFlagV2, ipsets.KeyLabelOfNamespace) + require.Contains(t, converter.NPMCache.GetSetMap(), metadata.GetHashedName()) + require.Equal(t, metadata.GetPrefixName(), converter.NPMCache.GetSetMap()[metadata.GetHashedName()]) +} diff --git a/npm/pkg/dataplane/testdata/npmcachev2.json b/npm/pkg/dataplane/testdata/npmcachev2.json index 8cd28343caf..6102ac8620c 100644 --- a/npm/pkg/dataplane/testdata/npmcachev2.json +++ b/npm/pkg/dataplane/testdata/npmcachev2.json @@ -442,7 +442,7 @@ "azure-npm-1343132199": "podlabel-version", "azure-npm-1385180724": "podlabel-pod-template-hash", "azure-npm-1529935048": "podlabel-component:tunnel", - "azure-npm-1639206293": "nslabel-all-namespaces", + "azure-npm-51v3fhaia2ucu2kg03hl": "nslabel-:all-namespaces", "azure-npm-1802501696": "nslabel-kubernetes.io/cluster-service", "azure-npm-1883894896": "ns-kube-node-lease", "azure-npm-1889013859": "podlabel-pod-template-hash:69c47794", From c180e456c3170d547458f06f961a6ff4968a54f2 Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Tue, 15 Sep 2026 02:03:58 +0000 Subject: [PATCH 28/33] fix: [NPM] preserve alternative branches in v2 diagnostics Clone each parent-to-child path independently, retain custom rule metadata, report all V2 matched sets, and keep IPBlock/named-port handling outside selector-only prechecks. Preserve the existing v1 path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 175c523d-ad1c-4e31-986d-7c9940c6c18c --- .../dataplane/debug/branch_matching_test.go | 81 +++++++++++++++++++ npm/pkg/dataplane/debug/converter.go | 63 +++++++++------ .../dataplane/debug/namespace_anchor_test.go | 1 + npm/pkg/dataplane/debug/trafficanalyzer.go | 11 ++- npm/pkg/dataplane/debug/version_mode_test.go | 4 +- 5 files changed, 130 insertions(+), 30 deletions(-) create mode 100644 npm/pkg/dataplane/debug/branch_matching_test.go diff --git a/npm/pkg/dataplane/debug/branch_matching_test.go b/npm/pkg/dataplane/debug/branch_matching_test.go new file mode 100644 index 00000000000..bfac8e8573d --- /dev/null +++ b/npm/pkg/dataplane/debug/branch_matching_test.go @@ -0,0 +1,81 @@ +package debug + +import ( + "testing" + + common "github.com/Azure/azure-container-networking/npm/pkg/controlplane/controllers/common" + "github.com/Azure/azure-container-networking/npm/pkg/dataplane/pb" + "github.com/Azure/azure-container-networking/npm/util" + "github.com/stretchr/testify/require" +) + +func TestV2MatchedSetsIncludeEveryCondition(t *testing.T) { + sets := []*pb.RuleResponse_SetInfo{ + {Name: util.NamespacePrefix + anchorPeerNamespace, HashedSetName: "namespace", Type: pb.SetType_NAMESPACE, Included: true}, + {Name: util.PodLabelPrefix + "app:shared", HashedSetName: "app-set", Type: pb.SetType_KEYLABELOFPOD, Included: true}, + } + rule := &pb.RuleResponse{Allowed: true, SrcList: sets} + hits, sourceSets, _, err := getHitRules( + &common.NpmPod{Namespace: anchorPeerNamespace, Labels: map[string]string{diagnosticAppLabelKey: diagnosticSharedValue}}, + &common.NpmPod{Namespace: anchorTargetNamespace}, + map[*pb.RuleResponse]struct{}{rule: {}}, &common.Cache{}, true, + ) + require.NoError(t, err) + require.ElementsMatch(t, []*pb.RuleResponse{rule}, hits) + require.Len(t, sourceSets, 2) + for _, set := range sets { + require.Equal(t, set, sourceSets[set.GetHashedSetName()]) + } +} + +func TestV2CIDRNamedPortDoesNotEnterSelectorPrecheck(t *testing.T) { + sets := []*pb.RuleResponse_SetInfo{ + {Name: util.CIDRPrefix + "peer", Type: pb.SetType_CIDRBLOCKS, Included: true}, + {Name: util.NamedPortIPSetPrefix + "web", Type: pb.SetType_NAMEDPORTS, Included: true}, + } + matched, err := matchNamespaceAnchorConditions( + "dst", &common.NpmPod{Namespace: anchorPeerNamespace}, + sets, &pb.RuleResponse{DstList: sets}, &common.Cache{}, true, + ) + require.NoError(t, err) + require.True(t, matched, "named ports must not turn an IPBlock peer into a selector peer") +} + +func TestV2ParentBranchesRemainAlternatives(t *testing.T) { + child := &pb.RuleResponse{ + Chain: EgressChainPrefix + "policy", Allowed: true, JumpTo: util.IptablesAzureAcceptChain, + DstList: []*pb.RuleResponse_SetInfo{{ + Name: util.NamespacePrefix + anchorTargetNamespace, Type: pb.SetType_NAMESPACE, Included: true, + }}, + } + first := &pb.RuleResponse{ + Chain: EgressChain, JumpTo: child.GetChain(), Comment: "first parent", + SrcList: []*pb.RuleResponse_SetInfo{{ + Name: util.NamespacePrefix + anchorPeerNamespace, Type: pb.SetType_NAMESPACE, Included: true, + }}, + } + second := &pb.RuleResponse{ + Chain: EgressChain, JumpTo: child.GetChain(), Comment: "second parent", + SrcList: []*pb.RuleResponse_SetInfo{{ + Name: util.NamespacePrefix + "another", Type: pb.SetType_NAMESPACE, Included: true, + }}, + } + merged := mergeV2ParentBranches(map[*pb.RuleResponse]struct{}{child: {}, first: {}, second: {}}) + require.Len(t, merged, 2) + require.Empty(t, child.GetSrcList(), "merging must not mutate the original child") + for branch := range merged { + require.Len(t, branch.GetSrcList(), 1) + require.Len(t, branch.GetDstList(), 1) + require.Equal(t, child.JumpTo, branch.JumpTo) + require.Contains(t, []string{first.Comment, second.Comment}, branch.Comment) + } + for _, namespace := range []string{anchorPeerNamespace, "another"} { + hits, _, _, err := getHitRules( + &common.NpmPod{Namespace: namespace}, &common.NpmPod{Namespace: anchorTargetNamespace}, + merged, &common.Cache{}, true, + ) + require.NoError(t, err) + require.Len(t, hits, 1) + require.Equal(t, child.GetChain(), hits[0].GetChain()) + } +} diff --git a/npm/pkg/dataplane/debug/converter.go b/npm/pkg/dataplane/debug/converter.go index e8e27a961cd..570d841a4a5 100644 --- a/npm/pkg/dataplane/debug/converter.go +++ b/npm/pkg/dataplane/debug/converter.go @@ -23,6 +23,7 @@ import ( "github.com/Azure/azure-container-networking/npm/pkg/models" "github.com/Azure/azure-container-networking/npm/util" "github.com/pkg/errors" + "google.golang.org/protobuf/proto" ) var ( @@ -286,36 +287,48 @@ func (c *Converter) pbRuleList(ipTable *NPMIPtable.Table) (map[*pb.RuleResponse] } if c.EnableV2NPM { - parentRules := make([]*pb.RuleResponse, 0) - for childRule := range allRulesInNPMChains { - - // if rule is a string-int, we need to find the parent jump - // to add the src for egress and dst for ingress - if strings.HasPrefix(childRule.Chain, EgressChainPrefix) { - for parentRule := range allRulesInNPMChains { - if strings.HasPrefix(parentRule.Chain, EgressChain) && parentRule.JumpTo == childRule.Chain { - childRule.SrcList = append(childRule.SrcList, parentRule.SrcList...) - childRule.Comment = parentRule.Comment - parentRules = append(parentRules, parentRule) - } - } + return mergeV2ParentBranches(allRulesInNPMChains), nil + } + + return allRulesInNPMChains, nil +} + +func mergeV2ParentBranches(rules map[*pb.RuleResponse]struct{}) map[*pb.RuleResponse]struct{} { + result := make(map[*pb.RuleResponse]struct{}, len(rules)) + parents := make(map[*pb.RuleResponse]struct{}) + for child := range rules { + matchedParent := false + for parent := range rules { + if parent.JumpTo != child.GetChain() { + continue } - if strings.HasPrefix(childRule.Chain, IngressChainPrefix) { - for parentRule := range allRulesInNPMChains { - if strings.HasPrefix(parentRule.Chain, IngressChain) && parentRule.JumpTo == childRule.Chain { - childRule.DstList = append(childRule.DstList, parentRule.DstList...) - childRule.Comment = parentRule.Comment - parentRules = append(parentRules, parentRule) - } - } + egress := strings.HasPrefix(child.GetChain(), EgressChainPrefix) && strings.HasPrefix(parent.GetChain(), EgressChain) + ingress := strings.HasPrefix(child.GetChain(), IngressChainPrefix) && strings.HasPrefix(parent.GetChain(), IngressChain) + if !egress && !ingress { + continue + } + // Separate jumps are alternatives, not additional conditions on one path. + branch := proto.CloneOf(child) + branch.JumpTo = child.JumpTo + parentBranch := proto.CloneOf(parent) + if egress { + branch.SrcList = append(branch.GetSrcList(), parentBranch.GetSrcList()...) + } else { + branch.DstList = append(branch.GetDstList(), parentBranch.GetDstList()...) } + branch.Comment = parent.Comment + result[branch] = struct{}{} + parents[parent] = struct{}{} + matchedParent = true } - for _, parentRule := range parentRules { - delete(allRulesInNPMChains, parentRule) + if !matchedParent { + result[child] = struct{}{} } } - - return allRulesInNPMChains, nil + for parent := range parents { + delete(result, parent) + } + return result } func (c *Converter) getRulesFromChain(iptableChain *NPMIPtable.Chain) ([]*pb.RuleResponse, error) { diff --git a/npm/pkg/dataplane/debug/namespace_anchor_test.go b/npm/pkg/dataplane/debug/namespace_anchor_test.go index 7e11f5d35bf..c5f7a54a3ac 100644 --- a/npm/pkg/dataplane/debug/namespace_anchor_test.go +++ b/npm/pkg/dataplane/debug/namespace_anchor_test.go @@ -16,6 +16,7 @@ const ( matchedTeamValue = "blue" otherLabelValue = "other" diagnosticAppLabelKey = "app" + diagnosticSharedValue = "shared" ) func TestV2NamespaceAggregateMatch(t *testing.T) { diff --git a/npm/pkg/dataplane/debug/trafficanalyzer.go b/npm/pkg/dataplane/debug/trafficanalyzer.go index 1d099f4499f..d013e412ddc 100644 --- a/npm/pkg/dataplane/debug/trafficanalyzer.go +++ b/npm/pkg/dataplane/debug/trafficanalyzer.go @@ -259,7 +259,9 @@ func getHitRules( if matchedSource { matchedSrc = true srcSets[setInfo.HashedSetName] = setInfo - break + if !enableV2NPM { + break + } } } @@ -277,7 +279,9 @@ func getHitRules( if matchedDestination { dstSets[setInfo.HashedSetName] = setInfo matchedDst = true - break + if !enableV2NPM { + break + } } } @@ -308,7 +312,8 @@ func matchNamespaceAnchorConditions(origin string, pod *common.NpmPod, sets []*p } hasSelectorCondition := false for _, set := range sets { - if set.GetType() != pb.SetType_CIDRBLOCKS && set.GetType() != pb.SetType_UNKNOWN { + if set.GetType() != pb.SetType_CIDRBLOCKS && set.GetType() != pb.SetType_UNKNOWN && + set.GetType() != pb.SetType_NAMEDPORTS { hasSelectorCondition = true break } diff --git a/npm/pkg/dataplane/debug/version_mode_test.go b/npm/pkg/dataplane/debug/version_mode_test.go index ee9d1b8166e..2bad785d609 100644 --- a/npm/pkg/dataplane/debug/version_mode_test.go +++ b/npm/pkg/dataplane/debug/version_mode_test.go @@ -43,7 +43,7 @@ func TestV2PodOnlyPeerRequiresNamespace(t *testing.T) { }}} rules := map[*pb.RuleResponse]struct{}{allow: {}, deny: {}} for _, namespace := range []string{anchorPeerNamespace, "different"} { - peer := &common.NpmPod{Namespace: namespace, Labels: map[string]string{diagnosticAppLabelKey: "shared"}} + peer := &common.NpmPod{Namespace: namespace, Labels: map[string]string{diagnosticAppLabelKey: diagnosticSharedValue}} hits, _, _, err := getHitRules(peer, target, rules, &common.Cache{}, true) require.NoError(t, err) want := []*pb.RuleResponse{deny} @@ -55,7 +55,7 @@ func TestV2PodOnlyPeerRequiresNamespace(t *testing.T) { } func TestV2PodConditionsWithoutNamespaceAreConjunctive(t *testing.T) { - peer := &common.NpmPod{Namespace: anchorPeerNamespace, Labels: map[string]string{diagnosticAppLabelKey: "shared", "role": otherLabelValue}} + peer := &common.NpmPod{Namespace: anchorPeerNamespace, Labels: map[string]string{diagnosticAppLabelKey: diagnosticSharedValue, "role": otherLabelValue}} allow := &pb.RuleResponse{Allowed: true, SrcList: []*pb.RuleResponse_SetInfo{ {Name: util.PodLabelPrefix + "app:shared", Type: pb.SetType_KEYLABELOFPOD, Included: true}, {Name: util.PodLabelPrefix + "role:required", Type: pb.SetType_KEYLABELOFPOD, Included: true}, From dfba14e148bfde3a9cad966df63e6032d12258c6 Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Tue, 15 Sep 2026 18:15:47 +0000 Subject: [PATCH 29/33] fix: [NPM] bound complete policy translation work Preflight combined selector and port expansion before materializing full-NPM policies, including cumulative match/member work and existing ACL limits. Reject unsupported Windows namespace negations during translation and guard negative destination sets before ACL conversion. Preserve Lite allocation scope and v1 behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 175c523d-ad1c-4e31-986d-7c9940c6c18c --- .../v2/namespace_selector_windows_test.go | 70 ++++++++ .../controlplane/translation/parseSelector.go | 137 +++++++------- .../translation/translatePolicy.go | 7 + .../translation/translatePolicy_test.go | 15 ++ .../controlplane/translation/work_budget.go | 168 ++++++++++++++++++ .../translation/work_budget_test.go | 135 ++++++++++++++ .../policies/negative_matches_windows_test.go | 28 +++ npm/pkg/dataplane/policies/policy_windows.go | 5 + 8 files changed, 490 insertions(+), 75 deletions(-) create mode 100644 npm/pkg/controlplane/controllers/v2/namespace_selector_windows_test.go create mode 100644 npm/pkg/controlplane/translation/work_budget.go create mode 100644 npm/pkg/controlplane/translation/work_budget_test.go create mode 100644 npm/pkg/dataplane/policies/negative_matches_windows_test.go diff --git a/npm/pkg/controlplane/controllers/v2/namespace_selector_windows_test.go b/npm/pkg/controlplane/controllers/v2/namespace_selector_windows_test.go new file mode 100644 index 00000000000..7ef174ddc6f --- /dev/null +++ b/npm/pkg/controlplane/controllers/v2/namespace_selector_windows_test.go @@ -0,0 +1,70 @@ +package controllers + +import ( + "fmt" + "testing" + + "github.com/Azure/azure-container-networking/npm/pkg/controlplane/translation" + dpmocks "github.com/Azure/azure-container-networking/npm/pkg/dataplane/mocks" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestWindowsFullNPMNamespaceNegationIsNotSubmitted(t *testing.T) { + for _, requirement := range []metav1.LabelSelectorRequirement{ + {Key: "tenant", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"a"}}, + {Key: "tenant", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"a", "b"}}, + {Key: "tenant", Operator: metav1.LabelSelectorOpDoesNotExist}, + } { + for _, direction := range []networkingv1.PolicyType{networkingv1.PolicyTypeIngress, networkingv1.PolicyTypeEgress} { + for _, combined := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%s/%v/combined=%t", direction, requirement.Operator, requirement.Values, combined), func(t *testing.T) { + peer := networkingv1.NetworkPolicyPeer{ + NamespaceSelector: &metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{requirement}}, + } + if combined { + peer.PodSelector = &metav1.LabelSelector{MatchLabels: map[string]string{"app": "client"}} + } + policy := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "namespace-selector", Namespace: "test", ResourceVersion: "1"}, + Spec: networkingv1.NetworkPolicySpec{PolicyTypes: []networkingv1.PolicyType{direction}}, + } + if direction == networkingv1.PolicyTypeIngress { + policy.Spec.Ingress = []networkingv1.NetworkPolicyIngressRule{{From: []networkingv1.NetworkPolicyPeer{peer}}} + } else { + policy.Spec.Egress = []networkingv1.NetworkPolicyEgressRule{{To: []networkingv1.NetworkPolicyPeer{peer}}} + } + translated, err := translation.TranslatePolicy(policy, false) + require.ErrorIs(t, err, translation.ErrUnsupportedNegativeMatch) + require.Nil(t, translated) + + ctrl := gomock.NewController(t) + dp := dpmocks.NewMockGenericDataplane(ctrl) + f := newNetPolQueueFixture(t, policy, dp, false) + c := f.netPolController + c.addNetworkPolicy(policy) + require.True(t, c.processNextWorkItem()) + require.Empty(t, c.rawNpSpecMap) + require.Zero(t, c.workqueue.Len()) + require.Zero(t, c.workqueue.NumRequeues(getKey(policy, t))) + + corrected := policy.DeepCopy() + corrected.ResourceVersion = "2" + positive := metav1.LabelSelectorRequirement{Key: "tenant", Operator: metav1.LabelSelectorOpExists} + if direction == networkingv1.PolicyTypeIngress { + corrected.Spec.Ingress[0].From[0].NamespaceSelector.MatchExpressions[0] = positive + } else { + corrected.Spec.Egress[0].To[0].NamespaceSelector.MatchExpressions[0] = positive + } + require.NoError(t, f.kubeInformer.Networking().V1().NetworkPolicies().Informer().GetIndexer().Update(corrected)) + dp.EXPECT().UpdatePolicy(gomock.Any()).Return(nil).Times(1) + c.updateNetworkPolicy(policy, corrected) + require.True(t, c.processNextWorkItem()) + require.Equal(t, &corrected.Spec, c.rawNpSpecMap[getKey(corrected, t)]) + }) + } + } + } +} diff --git a/npm/pkg/controlplane/translation/parseSelector.go b/npm/pkg/controlplane/translation/parseSelector.go index ba0f877b728..13299a255d9 100644 --- a/npm/pkg/controlplane/translation/parseSelector.go +++ b/npm/pkg/controlplane/translation/parseSelector.go @@ -91,58 +91,8 @@ func flattenNameSpaceSelector(nsSelector *metav1.LabelSelector) ([]metav1.LabelS return []metav1.LabelSelector{}, nil } - // Bound how many matches this selector produces, before anything is allocated and before - // the matchLabels-only shortcut below, since those labels each become a match too. A - // multi-value NotIn stays inside a single selector, so it is invisible to both the - // selector-count bound further down and the per-policy rule budget, yet every one of its - // values becomes its own IPSet and its own condition on one rule. - matches := len(nsSelector.MatchLabels) - branches := 1 - hasPositiveMatch := len(nsSelector.MatchLabels) > 0 - for _, req := range nsSelector.MatchExpressions { - switch req.Operator { - case metav1.LabelSelectorOpNotIn: - // each excluded value is carried as its own negated match - matches += len(req.Values) - case metav1.LabelSelectorOpIn: - // one match per branch, and a multi-value In fans out into branches - matches++ - hasPositiveMatch = true - if len(req.Values) > 1 { - // the branch count is bounded on its own terms first, so a selector that - // fans out too far still reports that rather than the total below. - // Divide rather than multiply so the product cannot overflow. - if len(req.Values) > maxFlattenedNSSelectors/branches { - return nil, fmt.Errorf("key %q with %d values expands past the %d selector limit: %w", - req.Key, len(req.Values), maxFlattenedNSSelectors, ErrTooManyFlattenedSelectors) - } - branches *= len(req.Values) - } - case metav1.LabelSelectorOpExists: - matches++ - hasPositiveMatch = true - case metav1.LabelSelectorOpDoesNotExist: - matches++ - default: - // an unknown operator, which the loop below rejects - matches++ - } - } - if !hasPositiveMatch { - // parseNSSelector anchors a selector that matches only negatively with the - // all-namespaces set, so that match counts too - matches++ - } - if matches > maxSelectorMatches { - return nil, fmt.Errorf("selector expands into %d matches, past the %d limit: %w", - matches, maxSelectorMatches, ErrTooManySelectorMatches) - } - // Each branch repeats every match, so the cost is the product rather than either factor. - // The branch count alone is bounded further down and the rule count by the policy budget, - // but neither sees a wide selector repeated across many branches. - if matches > maxTotalSelectorMatches/branches { - return nil, fmt.Errorf("selector expands into %d branches of %d matches, past the %d total match limit: %w", - branches, matches, maxTotalSelectorMatches, ErrTooManySelectorMatches) + if _, _, err := namespaceSelectorWork(nsSelector); err != nil { + return nil, err } if len(nsSelector.MatchExpressions) == 0 { @@ -168,15 +118,6 @@ func flattenNameSpaceSelector(nsSelector *metav1.LabelSelector) ([]metav1.LabelS // Exists/DoesNotExist carry no values and are added to baseSelector directly. switch { case req.Operator == metav1.LabelSelectorOpIn: - if len(req.Values) == 0 { - return nil, ErrEmptyMatchExpressionValues - } - for _, v := range req.Values { - if !isValidLabelValue(v) { - return nil, ErrInvalidMatchExpressionValues - } - } - if len(req.Values) == 1 { // for length 1, add the matchExpr to baseSelector baseSelector.MatchExpressions = append(baseSelector.MatchExpressions, req) @@ -187,15 +128,6 @@ func flattenNameSpaceSelector(nsSelector *metav1.LabelSelector) ([]metav1.LabelS multiValueMatchExprs = append(multiValueMatchExprs, req) } case req.Operator == metav1.LabelSelectorOpNotIn: - if len(req.Values) == 0 { - return nil, ErrEmptyMatchExpressionValues - } - for _, v := range req.Values { - if !isValidLabelValue(v) { - return nil, ErrInvalidMatchExpressionValues - } - } - if len(req.Values) == 1 { // for length 1, add the matchExpr to baseSelector baseSelector.MatchExpressions = append(baseSelector.MatchExpressions, req) @@ -269,11 +201,66 @@ func flattenNameSpaceSelector(nsSelector *metav1.LabelSelector) ([]metav1.LabelS return flatNsSelectors, nil } -// zipMatchExprs helps with zipping a given matchExpr with given baseLabelSelectors -// this func will loop over each baseSelector in the slice, -// deepCopies each baseSelector, combines with given matchExpr by looping over each value -// and creating a new LabelSelector with given baseSelector and value matchExpr -// then returns a new slice of these zipped LabelSelectors +// namespaceSelectorWork counts expansion before allocating selectors, sets, or ACLs. +func namespaceSelectorWork(nsSelector *metav1.LabelSelector) (branches, matches int, err error) { + matches = len(nsSelector.MatchLabels) + branches = 1 + hasPositiveMatch := len(nsSelector.MatchLabels) > 0 + for _, req := range nsSelector.MatchExpressions { + switch req.Operator { + case metav1.LabelSelectorOpNotIn: + matches += len(req.Values) + case metav1.LabelSelectorOpIn: + matches++ + hasPositiveMatch = true + if len(req.Values) > 1 { + if len(req.Values) > maxFlattenedNSSelectors/branches { + return 0, 0, fmt.Errorf("key %q with %d values expands past the %d selector limit: %w", + req.Key, len(req.Values), maxFlattenedNSSelectors, ErrTooManyFlattenedSelectors) + } + branches *= len(req.Values) + } + case metav1.LabelSelectorOpExists: + matches++ + hasPositiveMatch = true + case metav1.LabelSelectorOpDoesNotExist: + matches++ + default: + matches++ + } + } + if !hasPositiveMatch { + matches++ + } + if matches > maxSelectorMatches { + return 0, 0, fmt.Errorf("selector expands into %d matches, past the %d limit: %w", + matches, maxSelectorMatches, ErrTooManySelectorMatches) + } + if matches > maxTotalSelectorMatches/branches { + return 0, 0, fmt.Errorf("selector expands into %d branches of %d matches, past the %d total match limit: %w", + branches, matches, maxTotalSelectorMatches, ErrTooManySelectorMatches) + } + for _, requirement := range nsSelector.MatchExpressions { + switch requirement.Operator { + case metav1.LabelSelectorOpIn, metav1.LabelSelectorOpNotIn: + if len(requirement.Values) == 0 { + return 0, 0, ErrEmptyMatchExpressionValues + } + for _, value := range requirement.Values { + if !isValidLabelValue(value) { + return 0, 0, ErrInvalidMatchExpressionValues + } + } + case metav1.LabelSelectorOpExists, metav1.LabelSelectorOpDoesNotExist: + default: + return 0, 0, fmt.Errorf("operator %q on key %q: %w", + requirement.Operator, requirement.Key, ErrUnsupportedMatchExpressionOperator) + } + } + return branches, matches, nil +} + +// zipMatchExprs adds one alternative for each value to every existing branch. func zipMatchExprs(baseSelectors []metav1.LabelSelector, matchExpr metav1.LabelSelectorRequirement) []metav1.LabelSelector { zippedLabelSelectors := []metav1.LabelSelector{} for _, selector := range baseSelectors { diff --git a/npm/pkg/controlplane/translation/translatePolicy.go b/npm/pkg/controlplane/translation/translatePolicy.go index 406ee7f765c..933f02df964 100644 --- a/npm/pkg/controlplane/translation/translatePolicy.go +++ b/npm/pkg/controlplane/translation/translatePolicy.go @@ -56,6 +56,8 @@ var ( // willing to program. ACL count multiplies rather than adds: flattened selector branches are // emitted per port, summed across peers and rules, so bounding selectors alone is not enough. ErrTooManyACLs = errors.New("network policy expands into too many rules") + // ErrTooManyPolicyMatches covers combined selectors and replication across ACLs. + ErrTooManyPolicyMatches = errors.New("network policy expands into too many set matches") // ErrUnsupportedIPAddress is returned when an unsupported IP address, such as IPV6, is used ErrUnsupportedIPAddress = errors.New("unsupported IP address") // ErrInvalidIPBlockExcept is returned for an exclusion that cannot be a strict subset. @@ -813,6 +815,11 @@ func parseNodeEgressPorts(annotations map[string]string) []int32 { // TranslatePolicy translates networkpolicy object to NPMNetworkPolicy object // and returns the NPMNetworkPolicy object. func TranslatePolicy(npObj *networkingv1.NetworkPolicy, npmLiteToggle bool) (*policies.NPMNetworkPolicy, error) { + if !npmLiteToggle { + if err := validateFullPolicyWork(npObj); err != nil { + return nil, fmt.Errorf("network policy %s/%s: %w", npObj.Namespace, npObj.Name, err) + } + } netPolName := npObj.Name npmNetPol := policies.NewNPMNetworkPolicy(netPolName, npObj.Namespace) diff --git a/npm/pkg/controlplane/translation/translatePolicy_test.go b/npm/pkg/controlplane/translation/translatePolicy_test.go index b3f7cb045ab..3b6ca26579f 100644 --- a/npm/pkg/controlplane/translation/translatePolicy_test.go +++ b/npm/pkg/controlplane/translation/translatePolicy_test.go @@ -1521,6 +1521,11 @@ func TestTranslatePolicyNegationOnlyNamespaceSelector(t *testing.T) { pol := nsNotInPolicy("victim", "default", tenantLabelKey, tt.direction, nil, "x") npmNetPol, err := TranslatePolicy(pol, false) + if util.IsWindowsDP() { + require.ErrorIs(t, err, ErrUnsupportedNegativeMatch) + require.Nil(t, npmNetPol) + return + } require.NoError(t, err) var theAllow *policies.ACLPolicy @@ -1619,6 +1624,11 @@ func TestTranslatePolicyMultiValueNotInConjunction(t *testing.T) { pol := nsNotInPolicy("victim", "default", tenantLabelKey, tt.direction, tt.ports, "attacker", "quarantine") npmNetPol, err := TranslatePolicy(pol, false) + if util.IsWindowsDP() { + require.ErrorIs(t, err, ErrUnsupportedNegativeMatch) + require.Nil(t, npmNetPol) + return + } require.NoError(t, err) excluded := map[string]bool{"tenant:attacker": true, "tenant:quarantine": true} @@ -4043,6 +4053,11 @@ func TestTranslatePolicyNegationOnlyOperators(t *testing.T) { t.Parallel() npmNetPol, err := TranslatePolicy(nsExprPolicy("victim", "default", dir.direction, op.req), false) + if util.IsWindowsDP() { + require.ErrorIs(t, err, ErrUnsupportedNegativeMatch) + require.Nil(t, npmNetPol) + return + } require.NoError(t, err) var allowACLs int diff --git a/npm/pkg/controlplane/translation/work_budget.go b/npm/pkg/controlplane/translation/work_budget.go new file mode 100644 index 00000000000..78df6bb624e --- /dev/null +++ b/npm/pkg/controlplane/translation/work_budget.go @@ -0,0 +1,168 @@ +package translation + +import ( + "fmt" + + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +// Count copied ACL matches and generated members together before materialization. +const maxTotalPolicyMatches = maxTotalSelectorMatches + +type policyWorkBudget struct { + acls int + matches int +} + +func validateFullPolicyWork(policy *networkingv1.NetworkPolicy) error { + selectedMatches, selectedMembers, err := podSelectorWork(&policy.Spec.PodSelector) + if err != nil { + return err + } + if selectedMatches >= maxSelectorMatches { + return fmt.Errorf("selected pods require a namespace match: %w", ErrTooManySelectorMatches) + } + budget := policyWorkBudget{matches: selectedMatches + selectedMembers + 1} + if budget.matches > maxTotalPolicyMatches { + return fmt.Errorf("selected pod members exceed the %d policy match limit: %w", maxTotalPolicyMatches, ErrTooManyPolicyMatches) + } + for _, direction := range policy.Spec.PolicyTypes { + if direction == networkingv1.PolicyTypeIngress { + if isAllowAllToIngress(policy.Spec.Ingress) { + if err := budget.reserve(1, 0, nil, 0); err != nil { + return err + } + continue + } + for _, rule := range policy.Spec.Ingress { + if err := budget.rule(rule.Ports, rule.From); err != nil { + return err + } + } + } else { + if isAllowAllToEgress(policy.Spec.Egress) { + if err := budget.reserve(1, 0, nil, 0); err != nil { + return err + } + continue + } + for _, rule := range policy.Spec.Egress { + if err := budget.rule(rule.Ports, rule.To); err != nil { + return err + } + } + } + if err := budget.reserve(1, 0, nil, 0); err != nil { + return err + } + } + return nil +} + +func (budget *policyWorkBudget) rule(ports []networkingv1.NetworkPolicyPort, peers []networkingv1.NetworkPolicyPeer) error { + allowExternal, portRuleExists, peerRuleExists := ruleExists(ports, peers) + if portRuleExists && (!peerRuleExists || allowExternal) { + if err := budget.reserve(1, 0, ports, 0); err != nil { + return err + } + } + for _, peer := range peers { + if peer.IPBlock != nil { + if peer.IPBlock.CIDR != "" { + // A parent may split into two members; exclusions add at most one each. + if err := budget.reserve(1, 1, ports, len(peer.IPBlock.Except)+2); err != nil { + return err + } + } + continue + } + if peer.PodSelector == nil && peer.NamespaceSelector == nil { + continue + } + podMatches, podMembers, err := podSelectorWork(peer.PodSelector) + if err != nil { + return err + } + branches, namespaceMatches := 1, 1 + if peer.NamespaceSelector != nil { + branches, namespaceMatches, err = namespaceSelectorWork(peer.NamespaceSelector) + if err != nil { + return err + } + for _, requirement := range peer.NamespaceSelector.MatchExpressions { + if unsupportedOpsInWindows(requirement.Operator) { + return ErrUnsupportedNegativeMatch + } + } + } + if err := budget.reserve(branches, namespaceMatches+podMatches, ports, podMembers); err != nil { + return err + } + } + return nil +} + +func (budget *policyWorkBudget) reserve(branches, matches int, ports []networkingv1.NetworkPolicyPort, members int) error { + portCount := len(ports) + if portCount == 0 { + portCount = 1 + } + if portCount > (maxACLsPerPolicy-budget.acls)/branches { + return fmt.Errorf("policy exceeds the %d rule limit: %w", maxACLsPerPolicy, ErrTooManyACLs) + } + acls := branches * portCount + namedPorts := 0 + for _, port := range ports { + if port.Port != nil && port.Port.Type == intstr.String { + namedPorts++ + } + } + perACLMatches := matches + if namedPorts > 0 { + perACLMatches++ + } + if perACLMatches > maxSelectorMatches { + return fmt.Errorf("peer expands into %d matches per ACL, past the %d limit: %w", + perACLMatches, maxSelectorMatches, ErrTooManySelectorMatches) + } + remaining := maxTotalPolicyMatches - budget.matches + if members > remaining || matches > (remaining-members)/acls { + return fmt.Errorf("replicated matches exceed the %d policy match limit: %w", maxTotalPolicyMatches, ErrTooManyPolicyMatches) + } + work := members + matches*acls + if namedPorts > (remaining-work)/branches { + return fmt.Errorf("named-port matches exceed the %d policy match limit: %w", maxTotalPolicyMatches, ErrTooManyPolicyMatches) + } + budget.acls += acls + budget.matches += work + namedPorts*branches + return nil +} + +func podSelectorWork(selector *metav1.LabelSelector) (matches, members int, err error) { + if selector == nil { + return 0, 0, nil + } + matches = len(selector.MatchLabels) + len(selector.MatchExpressions) + if matches > maxSelectorMatches { + return 0, 0, fmt.Errorf("pod selector has %d matches, past the %d limit: %w", + matches, maxSelectorMatches, ErrTooManySelectorMatches) + } + for _, requirement := range selector.MatchExpressions { + if unsupportedOpsInWindows(requirement.Operator) { + return 0, 0, ErrUnsupportedNegativeMatch + } + if len(requirement.Values) > maxSelectorMatches { + return 0, 0, fmt.Errorf("pod requirement %q has %d values, past the %d limit: %w", + requirement.Key, len(requirement.Values), maxSelectorMatches, ErrTooManySelectorMatches) + } + if len(requirement.Values) > 1 { + if len(requirement.Values) > maxTotalPolicyMatches-members { + return 0, 0, fmt.Errorf("pod selector members exceed the %d policy match limit: %w", maxTotalPolicyMatches, ErrTooManyPolicyMatches) + } + members += len(requirement.Values) + } + } + return matches, members, nil +} diff --git a/npm/pkg/controlplane/translation/work_budget_test.go b/npm/pkg/controlplane/translation/work_budget_test.go new file mode 100644 index 00000000000..a97ec1382bf --- /dev/null +++ b/npm/pkg/controlplane/translation/work_budget_test.go @@ -0,0 +1,135 @@ +package translation + +import ( + "fmt" + "testing" + + "github.com/Azure/azure-container-networking/npm/pkg/dataplane/policies" + "github.com/stretchr/testify/require" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +func combinedBudgetPolicy(direction networkingv1.PolicyType, labelCount, branchCount, portCount int) *networkingv1.NetworkPolicy { + labels := make(map[string]string, labelCount) + for index := 0; index < labelCount; index++ { + labels[fmt.Sprintf("key%d", index)] = "value" + } + values := make([]string, branchCount) + for index := range values { + values[index] = fmt.Sprintf("branch%d", index) + } + ports := make([]networkingv1.NetworkPolicyPort, portCount) + for index := range ports { + port := intstr.FromInt(1000 + index) + ports[index].Port = &port + } + peer := networkingv1.NetworkPolicyPeer{ + PodSelector: &metav1.LabelSelector{MatchLabels: labels}, + NamespaceSelector: &metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{{ + Key: teamLabelKey, Operator: metav1.LabelSelectorOpIn, Values: values, + }}}, + } + policy := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "combined", Namespace: defaultNS}, + Spec: networkingv1.NetworkPolicySpec{PolicyTypes: []networkingv1.PolicyType{direction}}, + } + if direction == networkingv1.PolicyTypeIngress { + policy.Spec.Ingress = []networkingv1.NetworkPolicyIngressRule{{Ports: ports, From: []networkingv1.NetworkPolicyPeer{peer}}} + } else { + policy.Spec.Egress = []networkingv1.NetworkPolicyEgressRule{{Ports: ports, To: []networkingv1.NetworkPolicyPeer{peer}}} + } + return policy +} + +func TestCombinedPeerBudgetBeforeAllocation(t *testing.T) { + for _, direction := range []networkingv1.PolicyType{networkingv1.PolicyTypeIngress, networkingv1.PolicyTypeEgress} { + t.Run(string(direction), func(t *testing.T) { + policy := combinedBudgetPolicy(direction, 20000, 16, 124) + before := policy.DeepCopy() + var translated *policies.NPMNetworkPolicy + var err error + allocations := testing.AllocsPerRun(3, func() { + translated, err = TranslatePolicy(policy, false) + }) + require.ErrorIs(t, err, ErrTooManySelectorMatches) + require.Nil(t, translated) + require.Less(t, allocations, float64(64), "rejection must happen before per-label sets or branch ACLs are allocated") + require.Equal(t, before, policy) + }) + } +} + +func TestCombinedPeerPortReplicationBudget(t *testing.T) { + for _, direction := range []networkingv1.PolicyType{networkingv1.PolicyTypeIngress, networkingv1.PolicyTypeEgress} { + t.Run(string(direction), func(t *testing.T) { + policy := combinedBudgetPolicy(direction, 10, 16, 124) + translated, err := TranslatePolicy(policy, false) + require.ErrorIs(t, err, ErrTooManyPolicyMatches) + require.Nil(t, translated) + + control := combinedBudgetPolicy(direction, 4, 16, 124) + translated, err = TranslatePolicy(control, false) + require.NoError(t, err) + require.Len(t, translated.ACLs, 1985) + }) + } +} + +func TestPolicyMatchBudgetAccumulatesAcrossRulesAndDirections(t *testing.T) { + for _, dualDirection := range []bool{false, true} { + t.Run(fmt.Sprintf("dualDirection=%t", dualDirection), func(t *testing.T) { + policy := combinedBudgetPolicy(networkingv1.PolicyTypeIngress, 99, 1, 50) + if dualDirection { + policy.Spec.PolicyTypes = append(policy.Spec.PolicyTypes, networkingv1.PolicyTypeEgress) + policy.Spec.Egress = combinedBudgetPolicy(networkingv1.PolicyTypeEgress, 99, 1, 50).Spec.Egress + } else { + policy.Spec.Ingress = append(policy.Spec.Ingress, policy.Spec.Ingress[0]) + } + translated, err := TranslatePolicy(policy, false) + require.ErrorIs(t, err, ErrTooManyPolicyMatches) + require.Nil(t, translated) + }) + } +} + +func TestPolicyMatchBudgetExactBoundaryAndNamedPorts(t *testing.T) { + budget := policyWorkBudget{} + require.NoError(t, budget.reserve(10, maxSelectorMatches, nil, 0)) + require.Equal(t, maxTotalPolicyMatches, budget.matches) + require.NoError(t, budget.reserve(1, 0, nil, 0), "a zero-match default drop still fits") + before := budget + require.ErrorIs(t, budget.reserve(1, 1, nil, 0), ErrTooManyPolicyMatches) + require.Equal(t, before, budget, "rejection must not reserve partial work") + + namedPort := intstr.FromString("web") + ports := []networkingv1.NetworkPolicyPort{{Port: &namedPort}} + budget = policyWorkBudget{} + require.NoError(t, budget.reserve(1, maxSelectorMatches-1, ports, 0)) + require.Equal(t, maxSelectorMatches, budget.matches) + require.ErrorIs(t, budget.reserve(1, maxSelectorMatches, ports, 0), ErrTooManySelectorMatches) +} + +func TestSelectedPodAndNestedMemberBudgets(t *testing.T) { + policy := combinedBudgetPolicy(networkingv1.PolicyTypeIngress, 0, 1, 1) + policy.Spec.PodSelector.MatchLabels = make(map[string]string, maxSelectorMatches) + for index := 0; index < maxSelectorMatches; index++ { + policy.Spec.PodSelector.MatchLabels[fmt.Sprintf("selected%d", index)] = "value" + } + translated, err := TranslatePolicy(policy, false) + require.ErrorIs(t, err, ErrTooManySelectorMatches) + require.Nil(t, translated) + + policy.Spec.PodSelector = metav1.LabelSelector{} + values := make([]string, maxSelectorMatches+1) + for index := range values { + values[index] = fmt.Sprintf("value%d", index) + } + policy.Spec.Ingress[0].From[0].PodSelector = &metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{{ + Key: "app", Operator: metav1.LabelSelectorOpIn, Values: values, + }}} + translated, err = TranslatePolicy(policy, false) + require.ErrorIs(t, err, ErrTooManySelectorMatches) + require.Nil(t, translated) +} diff --git a/npm/pkg/dataplane/policies/negative_matches_windows_test.go b/npm/pkg/dataplane/policies/negative_matches_windows_test.go new file mode 100644 index 00000000000..03b7767636a --- /dev/null +++ b/npm/pkg/dataplane/policies/negative_matches_windows_test.go @@ -0,0 +1,28 @@ +package policies + +import ( + "fmt" + "testing" + + "github.com/Azure/azure-container-networking/npm/pkg/dataplane/ipsets" + "github.com/stretchr/testify/require" +) + +func TestWindowsACLRejectsNegativeSetsOnEitherSide(t *testing.T) { + for _, direction := range []Direction{Ingress, Egress} { + for _, destination := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/destination=%t", direction, destination), func(t *testing.T) { + acl := NewACLPolicy(Allowed, direction) + acl.Protocol = TCP + negative := NewSetInfo("tenant", ipsets.KeyLabelOfNamespace, false, SrcMatch) + if destination { + acl.DstList = []SetInfo{negative} + } else { + acl.SrcList = []SetInfo{negative} + } + _, err := acl.convertToAclSettings("test-policy") + require.ErrorIs(t, err, ErrNegativeMatchsNotSupported) + }) + } + } +} diff --git a/npm/pkg/dataplane/policies/policy_windows.go b/npm/pkg/dataplane/policies/policy_windows.go index d09f6716386..49b28d87017 100644 --- a/npm/pkg/dataplane/policies/policy_windows.go +++ b/npm/pkg/dataplane/policies/policy_windows.go @@ -73,6 +73,11 @@ func (acl *ACLPolicy) convertToAclSettings(aclID string) (*NPMACLPolSettings, er return policySettings, ErrNegativeMatchsNotSupported } } + for _, setInfo := range acl.DstList { + if !setInfo.Included { + return policySettings, ErrNegativeMatchsNotSupported + } + } if !acl.checkIPSets() { return policySettings, ErrNamedPortsNotSupported From 5fd61612e97e58d50693f5d0ec2bd3df560df52d Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Tue, 15 Sep 2026 19:05:08 +0000 Subject: [PATCH 30/33] fix: [NPM] require all v2 diagnostic set conditions Invalidate a v2 source or destination match when any set condition fails, while retaining v1 first-match behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../dataplane/debug/branch_matching_test.go | 89 +++++++++++++++++++ npm/pkg/dataplane/debug/trafficanalyzer.go | 6 ++ 2 files changed, 95 insertions(+) diff --git a/npm/pkg/dataplane/debug/branch_matching_test.go b/npm/pkg/dataplane/debug/branch_matching_test.go index bfac8e8573d..445ee4d3cd9 100644 --- a/npm/pkg/dataplane/debug/branch_matching_test.go +++ b/npm/pkg/dataplane/debug/branch_matching_test.go @@ -1,12 +1,14 @@ package debug import ( + "fmt" "testing" common "github.com/Azure/azure-container-networking/npm/pkg/controlplane/controllers/common" "github.com/Azure/azure-container-networking/npm/pkg/dataplane/pb" "github.com/Azure/azure-container-networking/npm/util" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" ) func TestV2MatchedSetsIncludeEveryCondition(t *testing.T) { @@ -41,6 +43,93 @@ func TestV2CIDRNamedPortDoesNotEnterSelectorPrecheck(t *testing.T) { require.True(t, matched, "named ports must not turn an IPBlock peer into a selector peer") } +func TestCIDRNamedPortConditionsRespectVersion(t *testing.T) { + const ( + matchingIP = "10.0.0.10" + ruleProtocol = "tcp" + ) + webPort := []corev1.ContainerPort{{Name: "web", ContainerPort: 8080, Protocol: corev1.ProtocolTCP}} + for _, enableV2 := range []bool{false, true} { + for _, sourceSide := range []bool{false, true} { + for _, cidrFirst := range []bool{false, true} { + for _, test := range []struct { + name string + ip string + ports []corev1.ContainerPort + wantV1 bool + wantV2 bool + }{ + {"both match", matchingIP, webPort, true, true}, + {"CIDR only", matchingIP, nil, true, false}, + {"named port only", "10.1.0.10", webPort, true, false}, + {"neither matches", "10.1.0.10", nil, false, false}, + { + "wrong protocol", matchingIP, + []corev1.ContainerPort{{Name: "web", ContainerPort: 8080, Protocol: corev1.ProtocolUDP}}, + true, false, + }, + } { + t.Run(fmt.Sprintf("v2=%t/source=%t/cidrFirst=%t/%s", enableV2, sourceSide, cidrFirst, test.name), func(t *testing.T) { + cidr := &pb.RuleResponse_SetInfo{ + Name: util.CIDRPrefix + "peer", HashedSetName: "cidr", Type: pb.SetType_CIDRBLOCKS, + Included: true, Contents: []string{"10.0.0.0/24"}, + } + namedPort := &pb.RuleResponse_SetInfo{ + Name: util.NamedPortIPSetPrefix + "web", HashedSetName: "named-port", Type: pb.SetType_NAMEDPORTS, + Included: true, + } + peerSets := []*pb.RuleResponse_SetInfo{namedPort, cidr} + if cidrFirst { + peerSets = []*pb.RuleResponse_SetInfo{cidr, namedPort} + } + targetSets := []*pb.RuleResponse_SetInfo{{ + Name: util.NamespacePrefix + anchorTargetNamespace, HashedSetName: "target", + Type: pb.SetType_NAMESPACE, Included: true, + }} + peer := &common.NpmPod{Namespace: anchorPeerNamespace, PodIP: test.ip, ContainerPorts: test.ports} + target := &common.NpmPod{Namespace: anchorTargetNamespace} + allow := &pb.RuleResponse{ + Allowed: true, Protocol: ruleProtocol, Direction: pb.Direction_EGRESS, + SrcList: targetSets, DstList: peerSets, + } + deny := &pb.RuleResponse{Direction: pb.Direction_EGRESS, SrcList: targetSets} + src, dst := target, peer + if sourceSide { + src, dst = peer, target + allow.Direction, deny.Direction = pb.Direction_INGRESS, pb.Direction_INGRESS + allow.SrcList, allow.DstList = peerSets, targetSets + deny.SrcList, deny.DstList = nil, targetSets + } + hits, srcSets, dstSets, err := getHitRules( + src, dst, map[*pb.RuleResponse]struct{}{allow: {}, deny: {}}, &common.Cache{}, enableV2, + ) + require.NoError(t, err) + wantMatch := test.wantV1 + if enableV2 { + wantMatch = test.wantV2 + } + want := []*pb.RuleResponse{deny} + if wantMatch { + want = append(want, allow) + } + require.ElementsMatch(t, want, hits) + if enableV2 && wantMatch { + matchedSets, port := dstSets, allow.GetDPort() + if sourceSide { + matchedSets, port = srcSets, allow.GetSPort() + } + require.Len(t, matchedSets, 2) + require.Equal(t, cidr, matchedSets[cidr.GetHashedSetName()]) + require.Equal(t, namedPort, matchedSets[namedPort.GetHashedSetName()]) + require.Equal(t, int32(8080), port) + } + }) + } + } + } + } +} + func TestV2ParentBranchesRemainAlternatives(t *testing.T) { child := &pb.RuleResponse{ Chain: EgressChainPrefix + "policy", Allowed: true, JumpTo: util.IptablesAzureAcceptChain, diff --git a/npm/pkg/dataplane/debug/trafficanalyzer.go b/npm/pkg/dataplane/debug/trafficanalyzer.go index d013e412ddc..97d8e61fdb0 100644 --- a/npm/pkg/dataplane/debug/trafficanalyzer.go +++ b/npm/pkg/dataplane/debug/trafficanalyzer.go @@ -262,6 +262,9 @@ func getHitRules( if !enableV2NPM { break } + } else if enableV2NPM { + matchedSrc = false + break } } @@ -282,6 +285,9 @@ func getHitRules( if !enableV2NPM { break } + } else if enableV2NPM { + matchedDst = false + break } } From 57c75b123e53fdcaef3ed3a4b2f5aa58a080d0e6 Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Tue, 15 Sep 2026 19:41:57 +0000 Subject: [PATCH 31/33] fix: [NPM] preserve diagnostic cache compatibility Keep namespace-label lookup local to the diagnostic consumer and retain the original generic cache contract. Clarify selector-limit errors and include the generated namespace anchor in the reported count. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2fb3f092-d680-4706-bdf3-11219fa5190d --- .../controlplane/controllers/common/cache.go | 1 - .../translation/translatePolicy.go | 8 +-- .../controlplane/translation/work_budget.go | 3 +- .../translation/work_budget_test.go | 3 + .../debug/cache_compatibility_test.go | 60 +++++++++++++++++++ npm/pkg/dataplane/debug/trafficanalyzer.go | 33 ++++++++-- 6 files changed, 95 insertions(+), 13 deletions(-) create mode 100644 npm/pkg/dataplane/debug/cache_compatibility_test.go diff --git a/npm/pkg/controlplane/controllers/common/cache.go b/npm/pkg/controlplane/controllers/common/cache.go index c03f9ff9afe..92bdd30aebf 100644 --- a/npm/pkg/controlplane/controllers/common/cache.go +++ b/npm/pkg/controlplane/controllers/common/cache.go @@ -46,7 +46,6 @@ const ( type GenericCache interface { GetPod(*Input) (*NpmPod, error) GetNamespaceLabel(namespace string, key string) string - GetNamespaceLabels(namespace string) (map[string]string, bool) GetListMap() map[string]string GetSetMap() map[string]string } diff --git a/npm/pkg/controlplane/translation/translatePolicy.go b/npm/pkg/controlplane/translation/translatePolicy.go index 933f02df964..5440c8fa96b 100644 --- a/npm/pkg/controlplane/translation/translatePolicy.go +++ b/npm/pkg/controlplane/translation/translatePolicy.go @@ -47,11 +47,9 @@ var ( // requirements would produce more labelSelectors than NPM is willing to translate. The count is // the product of the value counts, so it grows exponentially with the number of such requirements. ErrTooManyFlattenedSelectors = errors.New("namespaceSelector expands into too many label selectors") - // ErrTooManySelectorMatches is returned when a namespaceSelector expands into more set matches - // than NPM is willing to translate. A multi-value NotIn contributes one match per value while - // staying in a single selector, so it is counted by neither the flattened-selector bound nor the - // per-policy rule budget, yet each match becomes its own IPSet and its own condition on a rule. - ErrTooManySelectorMatches = errors.New("namespaceSelector expands into too many set matches") + // ErrTooManySelectorMatches covers excessive pod or namespace selector matches, + // including generated namespace anchors and named-port conditions. + ErrTooManySelectorMatches = errors.New("selector or rule expands into too many set matches") // ErrTooManyACLs is returned when a NetworkPolicy translates into more ACLs than NPM is // willing to program. ACL count multiplies rather than adds: flattened selector branches are // emitted per port, summed across peers and rules, so bounding selectors alone is not enough. diff --git a/npm/pkg/controlplane/translation/work_budget.go b/npm/pkg/controlplane/translation/work_budget.go index 78df6bb624e..0f041624b86 100644 --- a/npm/pkg/controlplane/translation/work_budget.go +++ b/npm/pkg/controlplane/translation/work_budget.go @@ -22,7 +22,8 @@ func validateFullPolicyWork(policy *networkingv1.NetworkPolicy) error { return err } if selectedMatches >= maxSelectorMatches { - return fmt.Errorf("selected pods require a namespace match: %w", ErrTooManySelectorMatches) + return fmt.Errorf("selected pod selector expands into %d matches including its namespace anchor, past the %d limit: %w", + selectedMatches+1, maxSelectorMatches, ErrTooManySelectorMatches) } budget := policyWorkBudget{matches: selectedMatches + selectedMembers + 1} if budget.matches > maxTotalPolicyMatches { diff --git a/npm/pkg/controlplane/translation/work_budget_test.go b/npm/pkg/controlplane/translation/work_budget_test.go index a97ec1382bf..ea92c4d9f99 100644 --- a/npm/pkg/controlplane/translation/work_budget_test.go +++ b/npm/pkg/controlplane/translation/work_budget_test.go @@ -119,6 +119,9 @@ func TestSelectedPodAndNestedMemberBudgets(t *testing.T) { } translated, err := TranslatePolicy(policy, false) require.ErrorIs(t, err, ErrTooManySelectorMatches) + require.ErrorContains(t, err, fmt.Sprintf("selected pod selector expands into %d matches including its namespace anchor, past the %d limit", + maxSelectorMatches+1, maxSelectorMatches)) + require.NotContains(t, err.Error(), "namespaceSelector") require.Nil(t, translated) policy.Spec.PodSelector = metav1.LabelSelector{} diff --git a/npm/pkg/dataplane/debug/cache_compatibility_test.go b/npm/pkg/dataplane/debug/cache_compatibility_test.go new file mode 100644 index 00000000000..4567a94eb6c --- /dev/null +++ b/npm/pkg/dataplane/debug/cache_compatibility_test.go @@ -0,0 +1,60 @@ +package debug + +import ( + "fmt" + "testing" + + common "github.com/Azure/azure-container-networking/npm/pkg/controlplane/controllers/common" + "github.com/Azure/azure-container-networking/npm/pkg/dataplane/pb" + "github.com/Azure/azure-container-networking/npm/util" + "github.com/stretchr/testify/require" +) + +// This implementation intentionally exposes only the original cache contract. +type legacyDiagnosticCache struct { + cache *common.Cache +} + +func (c *legacyDiagnosticCache) GetPod(input *common.Input) (*common.NpmPod, error) { + pod, err := c.cache.GetPod(input) + if err != nil { + return nil, fmt.Errorf("getting cached pod: %w", err) + } + return pod, nil +} + +func (c *legacyDiagnosticCache) GetNamespaceLabel(namespace, key string) string { + return c.cache.GetNamespaceLabel(namespace, key) +} + +func (c *legacyDiagnosticCache) GetListMap() map[string]string { + return c.cache.GetListMap() +} + +func (c *legacyDiagnosticCache) GetSetMap() map[string]string { + return c.cache.GetSetMap() +} + +func TestLegacyDiagnosticCacheCompatibility(t *testing.T) { + const labelKey = "team" + cache := &legacyDiagnosticCache{cache: &common.Cache{NsMap: map[string]*common.Namespace{ + util.NamespacePrefix + anchorPeerNamespace: {LabelsMap: map[string]string{labelKey: matchedTeamValue}}, + }}} + converter := &Converter{NPMCache: cache} + pod := &common.NpmPod{Namespace: anchorPeerNamespace} + set := &pb.RuleResponse_SetInfo{ + Name: util.NamespacePrefix + labelKey + ":" + matchedTeamValue, Type: pb.SetType_KEYVALUELABELOFNAMESPACE, Included: true, + } + rule := &pb.RuleResponse{Allowed: true, SrcList: []*pb.RuleResponse_SetInfo{set}} + hits, _, _, err := getHitRules(pod, &common.NpmPod{}, map[*pb.RuleResponse]struct{}{rule: {}}, converter.NPMCache, false) + require.NoError(t, err) + require.ElementsMatch(t, []*pb.RuleResponse{rule}, hits) + + set.Name = util.NamespaceLabelPrefix + labelKey + ":" + matchedTeamValue + matched, err := evaluateSetInfo("src", set, pod, rule, converter.NPMCache, true) + require.ErrorIs(t, err, errNamespaceLabelsUnavailable) + require.False(t, matched) + hits, _, _, err = getHitRules(pod, &common.NpmPod{}, map[*pb.RuleResponse]struct{}{rule: {}}, converter.NPMCache, true) + require.ErrorIs(t, err, errNamespaceLabelsUnavailable) + require.Nil(t, hits) +} diff --git a/npm/pkg/dataplane/debug/trafficanalyzer.go b/npm/pkg/dataplane/debug/trafficanalyzer.go index 97d8e61fdb0..5daaaac3cdc 100644 --- a/npm/pkg/dataplane/debug/trafficanalyzer.go +++ b/npm/pkg/dataplane/debug/trafficanalyzer.go @@ -1,6 +1,7 @@ package debug import ( + "errors" "fmt" "log" "net" @@ -310,6 +311,21 @@ func getHitRules( return res, srcSets, dstSets, nil } +type namespaceLabelReader interface { + GetNamespaceLabels(namespace string) (map[string]string, bool) +} + +var errNamespaceLabelsUnavailable = errors.New("diagnostic cache does not support namespace label lookup") + +func namespaceLabels(npmCache common.GenericCache, namespace string) (labels map[string]string, exists bool, err error) { + reader, ok := npmCache.(namespaceLabelReader) + if !ok { + return nil, false, errNamespaceLabelsUnavailable + } + labels, exists = reader.GetNamespaceLabels(namespace) + return labels, exists, nil +} + // V2 selector conditions are conjunctive, whether or not an aggregate was needed. // The converter's explicit mode keeps user-controlled v1 names outside this path. func matchNamespaceAnchorConditions(origin string, pod *common.NpmPod, sets []*pb.RuleResponse_SetInfo, rule *pb.RuleResponse, npmCache common.GenericCache, enableV2NPM bool) (bool, error) { @@ -328,7 +344,10 @@ func matchNamespaceAnchorConditions(origin string, pod *common.NpmPod, sets []*p return true, nil } - labels, namespaceExists := npmCache.GetNamespaceLabels(pod.Namespace) + labels, namespaceExists, err := namespaceLabels(npmCache, pod.Namespace) + if err != nil { + return false, err + } for _, set := range sets { var matches bool switch set.GetType() { @@ -501,12 +520,14 @@ func matchNESTEDLABELOFPOD(pod *common.NpmPod, setInfo *pb.RuleResponse_SetInfo) } func matchKEYLABELOFNAMESPACE(pod *common.NpmPod, npmCache common.GenericCache, setInfo *pb.RuleResponse_SetInfo, enableV2NPM bool) (bool, error) { - if enableV2NPM && setInfo.GetName() == util.NamespaceLabelPrefix+util.KubeAllNamespacesFlagV2 { - _, namespaceExists := npmCache.GetNamespaceLabels(pod.Namespace) - return setInfo.GetIncluded() == (pod.Namespace != "" && namespaceExists), nil - } if enableV2NPM { - labels, _ := npmCache.GetNamespaceLabels(pod.Namespace) + labels, namespaceExists, err := namespaceLabels(npmCache, pod.Namespace) + if err != nil { + return false, err + } + if setInfo.GetName() == util.NamespaceLabelPrefix+util.KubeAllNamespacesFlagV2 { + return setInfo.GetIncluded() == (pod.Namespace != "" && namespaceExists), nil + } matches, err := matchPrefixedLabelSet(labels, setInfo.GetName(), util.NamespaceLabelPrefix) if err != nil { return false, err From 240697f261fcf5c2fc2f87b3d784f86ebd077692 Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Tue, 15 Sep 2026 19:45:43 +0000 Subject: [PATCH 32/33] test: [NPM] share namespace selector fixture labels Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2fb3f092-d680-4706-bdf3-11219fa5190d --- .../v2/namespace_selector_windows_test.go | 16 ++++++++++------ .../v2/networkPolicyController_retry_test.go | 4 +++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/npm/pkg/controlplane/controllers/v2/namespace_selector_windows_test.go b/npm/pkg/controlplane/controllers/v2/namespace_selector_windows_test.go index 7ef174ddc6f..9ae55c08b8f 100644 --- a/npm/pkg/controlplane/controllers/v2/namespace_selector_windows_test.go +++ b/npm/pkg/controlplane/controllers/v2/namespace_selector_windows_test.go @@ -13,10 +13,14 @@ import ( ) func TestWindowsFullNPMNamespaceNegationIsNotSubmitted(t *testing.T) { + const ( + podLabelKey = "app" + namespaceName = "test" + ) for _, requirement := range []metav1.LabelSelectorRequirement{ - {Key: "tenant", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"a"}}, - {Key: "tenant", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"a", "b"}}, - {Key: "tenant", Operator: metav1.LabelSelectorOpDoesNotExist}, + {Key: namespaceSelectorLabelKey, Operator: metav1.LabelSelectorOpNotIn, Values: []string{"a"}}, + {Key: namespaceSelectorLabelKey, Operator: metav1.LabelSelectorOpNotIn, Values: []string{"a", "b"}}, + {Key: namespaceSelectorLabelKey, Operator: metav1.LabelSelectorOpDoesNotExist}, } { for _, direction := range []networkingv1.PolicyType{networkingv1.PolicyTypeIngress, networkingv1.PolicyTypeEgress} { for _, combined := range []bool{false, true} { @@ -25,10 +29,10 @@ func TestWindowsFullNPMNamespaceNegationIsNotSubmitted(t *testing.T) { NamespaceSelector: &metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{requirement}}, } if combined { - peer.PodSelector = &metav1.LabelSelector{MatchLabels: map[string]string{"app": "client"}} + peer.PodSelector = &metav1.LabelSelector{MatchLabels: map[string]string{podLabelKey: "client"}} } policy := &networkingv1.NetworkPolicy{ - ObjectMeta: metav1.ObjectMeta{Name: "namespace-selector", Namespace: "test", ResourceVersion: "1"}, + ObjectMeta: metav1.ObjectMeta{Name: "namespace-selector", Namespace: namespaceName, ResourceVersion: "1"}, Spec: networkingv1.NetworkPolicySpec{PolicyTypes: []networkingv1.PolicyType{direction}}, } if direction == networkingv1.PolicyTypeIngress { @@ -52,7 +56,7 @@ func TestWindowsFullNPMNamespaceNegationIsNotSubmitted(t *testing.T) { corrected := policy.DeepCopy() corrected.ResourceVersion = "2" - positive := metav1.LabelSelectorRequirement{Key: "tenant", Operator: metav1.LabelSelectorOpExists} + positive := metav1.LabelSelectorRequirement{Key: namespaceSelectorLabelKey, Operator: metav1.LabelSelectorOpExists} if direction == networkingv1.PolicyTypeIngress { corrected.Spec.Ingress[0].From[0].NamespaceSelector.MatchExpressions[0] = positive } else { diff --git a/npm/pkg/controlplane/controllers/v2/networkPolicyController_retry_test.go b/npm/pkg/controlplane/controllers/v2/networkPolicyController_retry_test.go index 5f5871d7a49..488e60a0e49 100644 --- a/npm/pkg/controlplane/controllers/v2/networkPolicyController_retry_test.go +++ b/npm/pkg/controlplane/controllers/v2/networkPolicyController_retry_test.go @@ -14,6 +14,8 @@ import ( "k8s.io/client-go/util/workqueue" ) +const namespaceSelectorLabelKey = "tenant" + func newNetPolQueueFixture(t *testing.T, policy *networkingv1.NetworkPolicy, dp *dpmocks.MockGenericDataplane, npmLite bool) *netPolFixture { t.Helper() f := newNetPolFixture(t) @@ -41,7 +43,7 @@ func TestFullNPMTranslationFailureWaitsForPolicyChange(t *testing.T) { unknownOperator := netPolWithCIDR("192.0.2.0/24") unknownOperator.Spec.Ingress[0].From = []networkingv1.NetworkPolicyPeer{{ NamespaceSelector: &metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{{ - Key: "tenant", Operator: "Unknown", + Key: namespaceSelectorLabelKey, Operator: "Unknown", }}}, }} From b5127fdbef4552ada0b4a2332abee4074da7e086 Mon Sep 17 00:00:00 2001 From: Isaiah Raya Date: Tue, 15 Sep 2026 20:06:39 +0000 Subject: [PATCH 33/33] fix: [NPM] validate selectors and CIDR exclusions Reject unsupported operators and empty pod-selector value lists before translation. Require canonical exclusions to be strict subsets of the canonical parent while preserving Windows Except handling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2fb3f092-d680-4706-bdf3-11219fa5190d --- .../translation/cidr_errors_test.go | 6 +++ .../controlplane/translation/parseSelector.go | 35 ++++++++----- .../translation/selector_validation_test.go | 51 +++++++++++++++++++ .../translation/translatePolicy.go | 21 +++++--- .../controlplane/translation/work_budget.go | 9 ++-- 5 files changed, 99 insertions(+), 23 deletions(-) create mode 100644 npm/pkg/controlplane/translation/selector_validation_test.go diff --git a/npm/pkg/controlplane/translation/cidr_errors_test.go b/npm/pkg/controlplane/translation/cidr_errors_test.go index 21867b2d23b..80c935e2eb0 100644 --- a/npm/pkg/controlplane/translation/cidr_errors_test.go +++ b/npm/pkg/controlplane/translation/cidr_errors_test.go @@ -16,6 +16,8 @@ func TestIPBlockNormalizationErrorCauses(t *testing.T) { malformedCIDR = "invalid" allAddresses = "0.0.0.0/0" hostBitsZero = "10.0.0.0/0" + privateCIDR = "10.0.0.0/8" + outsideCIDR = "192.0.2.0/24" ) for _, test := range []struct { name string @@ -34,6 +36,10 @@ func TestIPBlockNormalizationErrorCauses(t *testing.T) { {"IPv6 exclusion", networkingv1.IPBlock{CIDR: enclosingCIDR, Except: []string{ipv6CIDR}}, util.ErrUnsupportedIPFamily, true}, {"all-addresses exclusion", networkingv1.IPBlock{CIDR: allAddresses, Except: []string{allAddresses}}, ErrInvalidIPBlockExcept, true}, {"noncanonical all-addresses exclusion", networkingv1.IPBlock{CIDR: hostBitsZero, Except: []string{hostBitsZero}}, ErrInvalidIPBlockExcept, true}, + {"equal exclusion", networkingv1.IPBlock{CIDR: privateCIDR, Except: []string{privateCIDR}}, ErrInvalidIPBlockExcept, true}, + {"noncanonical equal exclusion", networkingv1.IPBlock{CIDR: privateCIDR, Except: []string{"10.1.2.3/8"}}, ErrInvalidIPBlockExcept, true}, + {"broader exclusion", networkingv1.IPBlock{CIDR: "10.1.0.0/16", Except: []string{privateCIDR}}, ErrInvalidIPBlockExcept, true}, + {"outside exclusion", networkingv1.IPBlock{CIDR: privateCIDR, Except: []string{outsideCIDR}}, ErrInvalidIPBlockExcept, true}, } { t.Run(test.name, func(t *testing.T) { unsupportedExcept := util.IsWindowsDP() && test.windowsExceptFailure diff --git a/npm/pkg/controlplane/translation/parseSelector.go b/npm/pkg/controlplane/translation/parseSelector.go index 13299a255d9..f723a962224 100644 --- a/npm/pkg/controlplane/translation/parseSelector.go +++ b/npm/pkg/controlplane/translation/parseSelector.go @@ -241,25 +241,32 @@ func namespaceSelectorWork(nsSelector *metav1.LabelSelector) (branches, matches branches, matches, maxTotalSelectorMatches, ErrTooManySelectorMatches) } for _, requirement := range nsSelector.MatchExpressions { - switch requirement.Operator { - case metav1.LabelSelectorOpIn, metav1.LabelSelectorOpNotIn: - if len(requirement.Values) == 0 { - return 0, 0, ErrEmptyMatchExpressionValues - } - for _, value := range requirement.Values { - if !isValidLabelValue(value) { - return 0, 0, ErrInvalidMatchExpressionValues - } - } - case metav1.LabelSelectorOpExists, metav1.LabelSelectorOpDoesNotExist: - default: - return 0, 0, fmt.Errorf("operator %q on key %q: %w", - requirement.Operator, requirement.Key, ErrUnsupportedMatchExpressionOperator) + if err := validateMatchExpression(requirement); err != nil { + return 0, 0, err } } return branches, matches, nil } +func validateMatchExpression(requirement metav1.LabelSelectorRequirement) error { + switch requirement.Operator { + case metav1.LabelSelectorOpIn, metav1.LabelSelectorOpNotIn: + if len(requirement.Values) == 0 { + return ErrEmptyMatchExpressionValues + } + for _, value := range requirement.Values { + if !isValidLabelValue(value) { + return ErrInvalidMatchExpressionValues + } + } + case metav1.LabelSelectorOpExists, metav1.LabelSelectorOpDoesNotExist: + default: + return fmt.Errorf("operator %q on key %q: %w", + requirement.Operator, requirement.Key, ErrUnsupportedMatchExpressionOperator) + } + return nil +} + // zipMatchExprs adds one alternative for each value to every existing branch. func zipMatchExprs(baseSelectors []metav1.LabelSelector, matchExpr metav1.LabelSelectorRequirement) []metav1.LabelSelector { zippedLabelSelectors := []metav1.LabelSelector{} diff --git a/npm/pkg/controlplane/translation/selector_validation_test.go b/npm/pkg/controlplane/translation/selector_validation_test.go new file mode 100644 index 00000000000..b514a947c5f --- /dev/null +++ b/npm/pkg/controlplane/translation/selector_validation_test.go @@ -0,0 +1,51 @@ +package translation + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestMalformedPodSelectorsFailBeforeTranslation(t *testing.T) { + for _, test := range []struct { + name string + requirement metav1.LabelSelectorRequirement + cause error + }{ + {"unknown operator", metav1.LabelSelectorRequirement{Key: appLabelKey, Operator: "Unknown"}, ErrUnsupportedMatchExpressionOperator}, + {"empty In", metav1.LabelSelectorRequirement{Key: appLabelKey, Operator: metav1.LabelSelectorOpIn}, ErrEmptyMatchExpressionValues}, + {"empty NotIn", metav1.LabelSelectorRequirement{Key: appLabelKey, Operator: metav1.LabelSelectorOpNotIn}, ErrEmptyMatchExpressionValues}, + {"invalid value", metav1.LabelSelectorRequirement{Key: appLabelKey, Operator: metav1.LabelSelectorOpIn, Values: []string{"invalid value"}}, ErrInvalidMatchExpressionValues}, + } { + for _, direction := range []networkingv1.PolicyType{networkingv1.PolicyTypeIngress, networkingv1.PolicyTypeEgress} { + for _, position := range []string{"selected", "peer", "combined peer"} { + t.Run(fmt.Sprintf("%s/%s/%s", test.name, direction, position), func(t *testing.T) { + policy := combinedBudgetPolicy(direction, 0, 1, 1) + selector := metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{test.requirement}} + if position == "selected" { + policy.Spec.PodSelector = selector + } else { + var peer *networkingv1.NetworkPolicyPeer + if direction == networkingv1.PolicyTypeIngress { + peer = &policy.Spec.Ingress[0].From[0] + } else { + peer = &policy.Spec.Egress[0].To[0] + } + peer.PodSelector = &selector + if position == "peer" { + peer.NamespaceSelector = nil + } + } + before := policy.DeepCopy() + translated, err := TranslatePolicy(policy, false) + require.ErrorIs(t, err, test.cause) + require.Nil(t, translated) + require.Equal(t, before, policy) + }) + } + } + } +} diff --git a/npm/pkg/controlplane/translation/translatePolicy.go b/npm/pkg/controlplane/translation/translatePolicy.go index 5440c8fa96b..eecf21d9453 100644 --- a/npm/pkg/controlplane/translation/translatePolicy.go +++ b/npm/pkg/controlplane/translation/translatePolicy.go @@ -3,6 +3,7 @@ package translation import ( "errors" "fmt" + "net/netip" "strconv" "strings" @@ -191,14 +192,18 @@ func deDuplicateExcept(exceptInIPBlock []string) []string { return deDupExcepts } -// canonicalizeExcepts returns the except CIDRs in canonical form, with duplicates removed. +// canonicalizeExcepts validates strict subsets and returns canonical, deduplicated except CIDRs. // Canonicalizing first means two spellings of the same block (e.g. "10.1.2.0/24" and // "10.1.2.3/24") collapse to one entry, and that an except can be compared against the // all-addresses split entries below. An except that is not an IPv4 CIDR cannot be programmed, // so it fails the translation rather than being carried into the set: dropping the exclusion // would widen the allow, and keeping it would take the whole set down at restore time. This is // used only on the ipset path. -func canonicalizeExcepts(exceptInIPBlock []string) ([]string, error) { +func canonicalizeExcepts(parentCIDR string, exceptInIPBlock []string) ([]string, error) { + parent, err := netip.ParsePrefix(parentCIDR) + if err != nil { + return nil, fmt.Errorf("ipBlock %q: %w: %w", parentCIDR, ErrUnsupportedIPAddress, err) + } canonicalExcepts := []string{} exceptsSet := make(map[string]struct{}) for _, except := range exceptInIPBlock { @@ -206,9 +211,13 @@ func canonicalizeExcepts(exceptInIPBlock []string) ([]string, error) { if err != nil { return nil, fmt.Errorf("except %q: %w: %w", except, ErrUnsupportedIPAddress, err) } - // An all-addresses exclusion cannot be a strict subset of any IPv4 CIDR. - if canonical == "0.0.0.0/0" { - return nil, fmt.Errorf("except %q: %w: %w", except, ErrUnsupportedIPAddress, ErrInvalidIPBlockExcept) + excluded, err := netip.ParsePrefix(canonical) + if err != nil { + return nil, fmt.Errorf("except %q: %w: %w", except, ErrUnsupportedIPAddress, err) + } + if excluded.Bits() <= parent.Bits() || !parent.Contains(excluded.Addr()) { + return nil, fmt.Errorf("except %q is not a strict subset of %q: %w: %w", + except, parentCIDR, ErrUnsupportedIPAddress, ErrInvalidIPBlockExcept) } if _, exist := exceptsSet[canonical]; !exist { canonicalExcepts = append(canonicalExcepts, canonical) @@ -240,7 +249,7 @@ func ipBlockIPSet(policyName, ns string, direction policies.Direction, ipBlockSe } // Canonicalize and deduplicate exclusions before comparing with the split entries. - deDupExcepts, err := canonicalizeExcepts(ipBlockRule.Except) + deDupExcepts, err := canonicalizeExcepts(cidr, ipBlockRule.Except) if err != nil { return nil, err } diff --git a/npm/pkg/controlplane/translation/work_budget.go b/npm/pkg/controlplane/translation/work_budget.go index 0f041624b86..af80b49387e 100644 --- a/npm/pkg/controlplane/translation/work_budget.go +++ b/npm/pkg/controlplane/translation/work_budget.go @@ -151,13 +151,16 @@ func podSelectorWork(selector *metav1.LabelSelector) (matches, members int, err matches, maxSelectorMatches, ErrTooManySelectorMatches) } for _, requirement := range selector.MatchExpressions { - if unsupportedOpsInWindows(requirement.Operator) { - return 0, 0, ErrUnsupportedNegativeMatch - } if len(requirement.Values) > maxSelectorMatches { return 0, 0, fmt.Errorf("pod requirement %q has %d values, past the %d limit: %w", requirement.Key, len(requirement.Values), maxSelectorMatches, ErrTooManySelectorMatches) } + if err := validateMatchExpression(requirement); err != nil { + return 0, 0, err + } + if unsupportedOpsInWindows(requirement.Operator) { + return 0, 0, ErrUnsupportedNegativeMatch + } if len(requirement.Values) > 1 { if len(requirement.Values) > maxTotalPolicyMatches-members { return 0, 0, fmt.Errorf("pod selector members exceed the %d policy match limit: %w", maxTotalPolicyMatches, ErrTooManyPolicyMatches)