From e6b7fabacdd3d3da122aca3515466c192f659859 Mon Sep 17 00:00:00 2001 From: entlein Date: Tue, 18 Aug 2026 19:06:01 +0200 Subject: [PATCH 1/3] Allow alert from unexpected Ports, allow Port=0 as intentional wildcard Signed-off-by: entlein --- .../containerprofilecache/projection_apply.go | 33 ++++ .../projection_golden_test.go | 7 + .../testdata/golden/network_all.json | 32 ++++ .../testdata/golden/rich_filtered.json | 2 + .../testdata/golden/rich_passthrough.json | 2 + pkg/objectcache/projection_types.go | 72 +++++++++ pkg/objectcache/v1/mock.go | 3 + .../containerprofilenetwork.go | 29 ++++ .../integration_test.go | 15 +- .../containerprofilenetwork/network.go | 141 +++++++++++++++++- .../containerprofilenetwork/network_test.go | 14 +- .../port_protocol_test.go | 47 ++++++ .../containerprofilenetwork/selector_test.go | 63 ++++++++ .../containerprofilenetwork/wildcard_test.go | 32 ++-- pkg/rulemanager/cel/selector_compile_test.go | 41 +++++ pkg/utils/cel.go | 29 ++++ .../templates/node-agent/default-rules.yaml | 2 +- tests/component_test.go | 60 ++++++++ ...containerprofile-user-defined-network.yaml | 33 ++++ tests/resources/network_fixture_lint_test.go | 5 +- tests/scripts/issue79-eol-ladder.sh | 107 +++++++++++++ 21 files changed, 717 insertions(+), 52 deletions(-) create mode 100644 pkg/rulemanager/cel/libraries/containerprofilenetwork/port_protocol_test.go create mode 100644 pkg/rulemanager/cel/libraries/containerprofilenetwork/selector_test.go create mode 100644 pkg/rulemanager/cel/selector_compile_test.go create mode 100755 tests/scripts/issue79-eol-ladder.sh diff --git a/pkg/objectcache/containerprofilecache/projection_apply.go b/pkg/objectcache/containerprofilecache/projection_apply.go index f0de872a52..60e7fb32bf 100644 --- a/pkg/objectcache/containerprofilecache/projection_apply.go +++ b/pkg/objectcache/containerprofilecache/projection_apply.go @@ -51,6 +51,10 @@ func Apply(spec *objectcache.RuleProjectionSpec, cp *v1beta1.ContainerProfile, c pcp.Execs = projectField(s.Execs, execsPaths, true) pcp.ExecsByPath = extractExecsByPath(cp) + pcp.Namespace = cp.Namespace + pcp.IngressPeers = extractIngressPeers(cp) + pcp.EgressPeers = extractEgressPeers(cp) + endpointPaths := extractEndpointPaths(cp) pcp.Endpoints = projectField(s.Endpoints, endpointPaths, true) @@ -63,6 +67,9 @@ func Apply(spec *objectcache.RuleProjectionSpec, cp *v1beta1.ContainerProfile, c pcp.IngressDomains = projectField(s.IngressDomains, extractIngressDomains(cp), false) pcp.IngressAddresses = projectField(s.IngressAddresses, extractIngressAddresses(cp), false) + pcp.EgressAddrPorts = objectcache.ExtractAddrPorts(cp.Spec.Egress) + pcp.IngressAddrPorts = objectcache.ExtractAddrPorts(cp.Spec.Ingress) + return pcp } @@ -260,3 +267,29 @@ func extractIngressAddresses(cp *v1beta1.ContainerProfile) []string { } return addrs } + +// extractIngressPeers / extractEgressPeers carry the label selectors of each +// network-neighbor entry so cp.was_selector_in_{ingress,egress} can match a +// peer by identity. Only entries that actually declare a podSelector are kept. +func extractIngressPeers(cp *v1beta1.ContainerProfile) []objectcache.PeerSelector { + return extractPeers(cp.Spec.Ingress) +} + +func extractEgressPeers(cp *v1beta1.ContainerProfile) []objectcache.PeerSelector { + return extractPeers(cp.Spec.Egress) +} + +func extractPeers(neighbors []v1beta1.NetworkNeighbor) []objectcache.PeerSelector { + var peers []objectcache.PeerSelector + for i := range neighbors { + n := &neighbors[i] + if n.PodSelector == nil { + continue + } + peers = append(peers, objectcache.PeerSelector{ + PodSelector: n.PodSelector, + NamespaceSelector: n.NamespaceSelector, + }) + } + return peers +} diff --git a/pkg/objectcache/containerprofilecache/projection_golden_test.go b/pkg/objectcache/containerprofilecache/projection_golden_test.go index 750d120bcb..1450907e29 100644 --- a/pkg/objectcache/containerprofilecache/projection_golden_test.go +++ b/pkg/objectcache/containerprofilecache/projection_golden_test.go @@ -65,6 +65,8 @@ type projectionGolden struct { EgressAddresses objectcache.ProjectedField `json:"egressAddresses"` IngressDomains objectcache.ProjectedField `json:"ingressDomains"` IngressAddresses objectcache.ProjectedField `json:"ingressAddresses"` + IngressPeers []objectcache.PeerSelector `json:"ingressPeers"` + EgressPeers []objectcache.PeerSelector `json:"egressPeers"` ExecsByPath map[string][][]string `json:"execsByPath"` PolicyByRuleId map[string]v1beta1.RulePolicy `json:"policyByRuleId"` CallStacks []callStackSummary `json:"callStacks"` @@ -93,6 +95,8 @@ func toGolden(pcp *objectcache.ProjectedContainerProfile, tree *callstackcache.C EgressAddresses: pcp.EgressAddresses, IngressDomains: pcp.IngressDomains, IngressAddresses: pcp.IngressAddresses, + IngressPeers: pcp.IngressPeers, + EgressPeers: pcp.EgressPeers, ExecsByPath: pcp.ExecsByPath, PolicyByRuleId: pcp.PolicyByRuleId, } @@ -236,9 +240,12 @@ func networkProfile() *v1beta1.ContainerProfile { Ingress: []v1beta1.NetworkNeighbor{ {Identifier: "in-a", DNS: "old.internal", DNSNames: []string{"a.internal", "b.internal"}, IPAddresses: []string{"192.168.1.10", "192.168.0.0/16"}}, {Identifier: "in-b", IPAddresses: []string{wild}}, + {Identifier: "in-c", PodSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "redis-client"}}}, + {Identifier: "in-d", PodSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "probe"}}, NamespaceSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"kubernetes.io/metadata.name": "monitoring"}}}, }, Egress: []v1beta1.NetworkNeighbor{ {Identifier: "eg-a", DNSNames: []string{"c.example.com"}, IPAddress: "203.0.113.7", IPAddresses: []string{"203.0.113.0/24", wild}}, + {Identifier: "eg-b", PodSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "upstream"}}}, }, }, } diff --git a/pkg/objectcache/containerprofilecache/testdata/golden/network_all.json b/pkg/objectcache/containerprofilecache/testdata/golden/network_all.json index 3b833c5cfd..8607a33268 100644 --- a/pkg/objectcache/containerprofilecache/testdata/golden/network_all.json +++ b/pkg/objectcache/containerprofilecache/testdata/golden/network_all.json @@ -78,6 +78,38 @@ "PrefixHits": {}, "SuffixHits": {} }, + "ingressPeers": [ + { + "PodSelector": { + "matchLabels": { + "app": "redis-client" + } + }, + "NamespaceSelector": null + }, + { + "PodSelector": { + "matchLabels": { + "app": "probe" + } + }, + "NamespaceSelector": { + "matchLabels": { + "kubernetes.io/metadata.name": "monitoring" + } + } + } + ], + "egressPeers": [ + { + "PodSelector": { + "matchLabels": { + "app": "upstream" + } + }, + "NamespaceSelector": null + } + ], "execsByPath": null, "policyByRuleId": null, "callStacks": null diff --git a/pkg/objectcache/containerprofilecache/testdata/golden/rich_filtered.json b/pkg/objectcache/containerprofilecache/testdata/golden/rich_filtered.json index dccf5126c1..71fb76abb4 100644 --- a/pkg/objectcache/containerprofilecache/testdata/golden/rich_filtered.json +++ b/pkg/objectcache/containerprofilecache/testdata/golden/rich_filtered.json @@ -103,6 +103,8 @@ "PrefixHits": {}, "SuffixHits": {} }, + "ingressPeers": null, + "egressPeers": null, "execsByPath": { "/bin/curl": [ [ diff --git a/pkg/objectcache/containerprofilecache/testdata/golden/rich_passthrough.json b/pkg/objectcache/containerprofilecache/testdata/golden/rich_passthrough.json index a343f73b67..13eef20a98 100644 --- a/pkg/objectcache/containerprofilecache/testdata/golden/rich_passthrough.json +++ b/pkg/objectcache/containerprofilecache/testdata/golden/rich_passthrough.json @@ -97,6 +97,8 @@ "PrefixHits": {}, "SuffixHits": {} }, + "ingressPeers": null, + "egressPeers": null, "execsByPath": { "/bin/curl": [ [ diff --git a/pkg/objectcache/projection_types.go b/pkg/objectcache/projection_types.go index 8d452c7a36..a8f7e4944a 100644 --- a/pkg/objectcache/projection_types.go +++ b/pkg/objectcache/projection_types.go @@ -1,10 +1,25 @@ package objectcache import ( + "strconv" + "strings" + "github.com/kubescape/node-agent/pkg/objectcache/callstackcache" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// PeerSelector carries a single network-neighbor entry's identity selectors +// (podSelector + namespaceSelector) through the projection so the +// cp.was_selector_in_{ingress,egress} CEL helpers can resolve a runtime peer +// IP to a pod and match it by LABEL rather than by (volatile) IP. The address +// surfaces (Ingress/EgressAddresses) still carry the ipAddress/CIDR form for +// the was_address_in_* helpers; these are complementary. +type PeerSelector struct { + PodSelector *metav1.LabelSelector + NamespaceSelector *metav1.LabelSelector +} + // PathMatcher is implemented by the trie-based matchers in containerprofilecache. type PathMatcher interface { HasMatch(s string) bool @@ -41,9 +56,54 @@ type FieldSpec struct { SuffixMatcher PathMatcher } +// AddrPortGroup pairs one neighbor entry's addresses with its allowed ports. +// Empty Ports means any port (port 0 or no ports declared = wildcard). +type AddrPortGroup struct { + Addrs []string + Ports map[string]struct{} +} + +func PortKey(protocol string, port int32) string { + return strings.ToUpper(protocol) + "-" + strconv.Itoa(int(port)) +} + +func ExtractAddrPorts(neighbors []v1beta1.NetworkNeighbor) []AddrPortGroup { + var groups []AddrPortGroup + for i := range neighbors { + n := &neighbors[i] + var addrs []string + if n.IPAddress != "" { + addrs = append(addrs, n.IPAddress) + } + addrs = append(addrs, n.IPAddresses...) + if len(addrs) == 0 { + continue + } + ports := make(map[string]struct{}, len(n.Ports)) + wildcard := len(n.Ports) == 0 + for _, p := range n.Ports { + if p.Port == nil || *p.Port == 0 { + wildcard = true + continue + } + ports[PortKey(string(p.Protocol), *p.Port)] = struct{}{} + } + if wildcard { + ports = nil + } + groups = append(groups, AddrPortGroup{Addrs: addrs, Ports: ports}) + } + return groups +} + // ProjectedContainerProfile is the cache-resident compact form. Pure node-agent // internal type; never serialized. Replaces *v1beta1.ContainerProfile in the cache. type ProjectedContainerProfile struct { + // Namespace is the profiled workload's own namespace; a peer entry whose + // NamespaceSelector is nil matches only peers in this namespace (the learned + // encoding and the NetworkPolicyPeer semantic for an absent namespaceSelector). + Namespace string + Opens ProjectedField Execs ProjectedField Endpoints ProjectedField @@ -54,6 +114,18 @@ type ProjectedContainerProfile struct { IngressDomains ProjectedField IngressAddresses ProjectedField + // IngressPeers / EgressPeers carry the podSelector+namespaceSelector of each + // network-neighbor entry (dropped by the address/domain projection) so the + // cp.was_selector_in_{ingress,egress} helpers can match a runtime peer by + // label. Always projected in full (not gated by a rule surface) since they + // are small and only populated when the profile actually declares selectors. + IngressPeers []PeerSelector + EgressPeers []PeerSelector + + // IngressAddrPorts / EgressAddrPorts group each neighbor's addresses with its ports for was_address_port_protocol_in_*. + IngressAddrPorts []AddrPortGroup + EgressAddrPorts []AddrPortGroup + // ExecsByPath carries the per-Path Args slices from cp.Spec.Execs so // downstream consumers (e.g. dynamicpathdetector.CompareExecArgs used // by R0040 in node-agent#807) can run wildcard-aware argv matching diff --git a/pkg/objectcache/v1/mock.go b/pkg/objectcache/v1/mock.go index 789eccb9ec..5066b933cc 100644 --- a/pkg/objectcache/v1/mock.go +++ b/pkg/objectcache/v1/mock.go @@ -193,6 +193,9 @@ func (r *RuleObjectCacheMock) GetProjectedContainerProfile(containerID string) * } } + pcp.EgressAddrPorts = objectcache.ExtractAddrPorts(cp.Spec.Egress) + pcp.IngressAddrPorts = objectcache.ExtractAddrPorts(cp.Spec.Ingress) + return pcp } diff --git a/pkg/rulemanager/cel/libraries/containerprofilenetwork/containerprofilenetwork.go b/pkg/rulemanager/cel/libraries/containerprofilenetwork/containerprofilenetwork.go index 58058c2aed..d9c7a2938d 100644 --- a/pkg/rulemanager/cel/libraries/containerprofilenetwork/containerprofilenetwork.go +++ b/pkg/rulemanager/cel/libraries/containerprofilenetwork/containerprofilenetwork.go @@ -83,6 +83,9 @@ type containerProfileNetworkFuncSpec struct { arity int // call invokes the shared implementation method on l. call func(l *containerProfileNetworkLibrary, args []ref.Val) ref.Val + // noCache bypasses the functionCache: a map argument has no stable scalar + // cache key, and the selector match is cheap (O(selectors)). + noCache bool } var containerProfileNetworkFuncSpecs = []containerProfileNetworkFuncSpec{ @@ -140,6 +143,26 @@ var containerProfileNetworkFuncSpecs = []containerProfileNetworkFuncSpec{ return l.wasAddressPortProtocolInIngress(a[0], a[1], a[2], a[3]) }, }, + { + name: "was_selector_in_egress", + argTypes: []*cel.Type{cel.StringType, cel.StringType, cel.MapType(cel.StringType, cel.StringType)}, + resultType: cel.BoolType, + arity: 3, + call: func(l *containerProfileNetworkLibrary, a []ref.Val) ref.Val { + return l.wasSelectorInEgress(a[0], a[1], a[2]) + }, + noCache: true, + }, + { + name: "was_selector_in_ingress", + argTypes: []*cel.Type{cel.StringType, cel.StringType, cel.MapType(cel.StringType, cel.StringType)}, + resultType: cel.BoolType, + arity: 3, + call: func(l *containerProfileNetworkLibrary, a []ref.Val) ref.Val { + return l.wasSelectorInIngress(a[0], a[1], a[2]) + }, + noCache: true, + }, } // declarationsWithPrefix builds the cel.FunctionOpt map for every function in @@ -165,6 +188,9 @@ func (l *containerProfileNetworkLibrary) declarationsWithPrefix(namePrefix, over if l.detailedMetrics && l.metrics != nil { l.metrics.IncHelperCall(fullName) } + if spec.noCache { + return cache.ConvertProfileNotAvailableErrToBool(spec.call(l, values), false) + } wrapperFunc := func(args ...ref.Val) ref.Val { return spec.call(l, args) } @@ -270,6 +296,9 @@ func (e *containerProfileNetworkCostEstimator) EstimateCallCost(function, overlo case "cp.is_domain_in_egress", "cp.is_domain_in_ingress": // Cache lookup + O(n) list iteration + O(m) slice.Contains on DNS names per entry cost = 35 + case "cp.was_selector_in_egress", "cp.was_selector_in_ingress": + // O(selectors) label-set match per peer entry + cost = 30 case "cp.was_address_port_protocol_in_egress", "cp.was_address_port_protocol_in_ingress": // Cache lookup + O(n) address search + O(p) nested port/protocol matching cost = 45 diff --git a/pkg/rulemanager/cel/libraries/containerprofilenetwork/integration_test.go b/pkg/rulemanager/cel/libraries/containerprofilenetwork/integration_test.go index e515a5fd73..81143c0133 100644 --- a/pkg/rulemanager/cel/libraries/containerprofilenetwork/integration_test.go +++ b/pkg/rulemanager/cel/libraries/containerprofilenetwork/integration_test.go @@ -209,28 +209,24 @@ func TestIntegrationWithAllNetworkFunctions(t *testing.T) { expectedResult: true, }, { - // v1 degradation: port/protocol projection is out of scope; address IS in profile → true. name: "Check non-existent egress address with port and protocol", expression: `cp.was_address_port_protocol_in_egress(containerID, "192.168.1.100", 9999, "TCP")`, - expectedResult: true, + expectedResult: false, }, { - // v1 degradation: port/protocol projection is out of scope; address IS in profile → true. name: "Check non-existent ingress address with port and protocol", expression: `cp.was_address_port_protocol_in_ingress(containerID, "172.16.0.10", 9999, "TCP")`, - expectedResult: true, + expectedResult: false, }, { - // v1 degradation: port/protocol projection is out of scope; address IS in profile → true. name: "Check wrong protocol for existing address and port", expression: `cp.was_address_port_protocol_in_egress(containerID, "192.168.1.100", 80, "UDP")`, - expectedResult: true, + expectedResult: false, }, { - // v1 degradation: port/protocol projection is out of scope; address IS in profile → true. name: "Check wrong protocol for existing ingress address and port", expression: `cp.was_address_port_protocol_in_ingress(containerID, "172.16.0.10", 8080, "UDP")`, - expectedResult: true, + expectedResult: false, }, { name: "Complex network check with port and protocol - egress", @@ -243,10 +239,9 @@ func TestIntegrationWithAllNetworkFunctions(t *testing.T) { expectedResult: true, }, { - // v1 degradation: both sides match on address only → true. name: "Mixed valid and invalid port protocol checks", expression: `cp.was_address_port_protocol_in_egress(containerID, "192.168.1.100", 80, "TCP") && cp.was_address_port_protocol_in_egress(containerID, "192.168.1.100", 9999, "TCP")`, - expectedResult: true, + expectedResult: false, }, } diff --git a/pkg/rulemanager/cel/libraries/containerprofilenetwork/network.go b/pkg/rulemanager/cel/libraries/containerprofilenetwork/network.go index 3d97e85f2e..ff1bbed68e 100644 --- a/pkg/rulemanager/cel/libraries/containerprofilenetwork/network.go +++ b/pkg/rulemanager/cel/libraries/containerprofilenetwork/network.go @@ -2,6 +2,7 @@ package containerprofilenetwork import ( "net" + "reflect" "strings" "github.com/google/cel-go/common/types" @@ -10,6 +11,8 @@ import ( "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache" "github.com/kubescape/node-agent/pkg/rulemanager/profilehelper" "github.com/kubescape/storage/pkg/registry/file/networkmatch" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" ) // matchIPField is the wildcard-aware adapter from the projection layer's @@ -59,6 +62,29 @@ func matchIPField(field *objectcache.ProjectedField, observed string) bool { return networkmatch.MatchIP(entries, observed) } +// matchAddrPort reports whether observed (address, protocol, port) falls within +// any single neighbor entry: its addresses match AND the entry allows the port +// (empty Ports = any port). Address-only entries thus stay wildcard on ports. +func matchAddrPort(groups []objectcache.AddrPortGroup, address, protocol string, port int32) bool { + if address == "" { + return false + } + key := objectcache.PortKey(protocol, port) + for i := range groups { + g := &groups[i] + if !networkmatch.MatchIP(g.Addrs, address) { + continue + } + if len(g.Ports) == 0 { + return true + } + if _, ok := g.Ports[key]; ok { + return true + } + } + return false +} + func matchDNSField(field *objectcache.ProjectedField, observed string) bool { if observed == "" || field == nil { return false @@ -171,9 +197,6 @@ func (l *containerProfileNetworkLibrary) wasAddressPortProtocolInEgress(containe if !ok { return types.MaybeNoSuchOverloadErr(address) } - // port/protocol projection (AddressPortsByAddr) is out of scope for the - // projection-v1 layer upstream landed; matchers degrade to address-only. - // Wildcards remain enforced via matchIPField. portInt, ok := port.Value().(int64) if !ok { return types.MaybeNoSuchOverloadErr(port) @@ -181,14 +204,15 @@ func (l *containerProfileNetworkLibrary) wasAddressPortProtocolInEgress(containe if portInt < 0 || portInt > 65535 { return types.Bool(false) } - if _, ok := protocol.Value().(string); !ok { + protocolStr, ok := protocol.Value().(string) + if !ok { return types.MaybeNoSuchOverloadErr(protocol) } cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - return types.Bool(matchIPField(&cp.EgressAddresses, addressStr)) + return types.Bool(matchAddrPort(cp.EgressAddrPorts, addressStr, protocolStr, int32(portInt))) } func (l *containerProfileNetworkLibrary) wasAddressPortProtocolInIngress(containerID, address, port, protocol ref.Val) ref.Val { @@ -210,12 +234,115 @@ func (l *containerProfileNetworkLibrary) wasAddressPortProtocolInIngress(contain if portInt < 0 || portInt > 65535 { return types.Bool(false) } - if _, ok := protocol.Value().(string); !ok { + protocolStr, ok := protocol.Value().(string) + if !ok { return types.MaybeNoSuchOverloadErr(protocol) } cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - return types.Bool(matchIPField(&cp.IngressAddresses, addressStr)) + return types.Bool(matchAddrPort(cp.IngressAddrPorts, addressStr, protocolStr, int32(portInt))) +} + +// namespaceSelectorMatches matches a namespaceSelector against the peer's +// namespace via the implicit kubernetes.io/metadata.name label every namespace +// carries (the form these profiles use). A nil selector matches only the +// profiled workload's own namespace: the learned generator omits the selector +// exactly for same-namespace peers, and NetworkPolicyPeer gives an absent +// namespaceSelector the same meaning. Selectors keyed on other namespace +// labels are not resolved here. +func namespaceSelectorMatches(sel *metav1.LabelSelector, ns, profileNs string) bool { + if sel == nil { + return ns == profileNs + } + s, err := metav1.LabelSelectorAsSelector(sel) + if err != nil { + return false + } + return s.Matches(labels.Set{"kubernetes.io/metadata.name": ns}) +} + +// wasSelectorInPeers reports whether the peer identified by (podLabels, ns) +// matches any peer entry's podSelector AND its namespaceSelector. +func wasSelectorInPeers(peers []objectcache.PeerSelector, podLabels labels.Set, ns, profileNs string) bool { + for i := range peers { + peer := &peers[i] + if peer.PodSelector == nil { + continue + } + ps, err := metav1.LabelSelectorAsSelector(peer.PodSelector) + if err != nil { + continue + } + if ps.Matches(podLabels) && namespaceSelectorMatches(peer.NamespaceSelector, ns, profileNs) { + return true + } + } + return false +} + +func (l *containerProfileNetworkLibrary) wasSelectorInIngress(containerID, namespace, podLabels ref.Val) ref.Val { + return l.wasSelectorIn(containerID, namespace, podLabels, true) +} + +func (l *containerProfileNetworkLibrary) wasSelectorInEgress(containerID, namespace, podLabels ref.Val) ref.Val { + return l.wasSelectorIn(containerID, namespace, podLabels, false) +} + +// wasSelectorIn reports whether the runtime peer — identified by the namespace +// and pod labels that Inspektor Gadget's kubeipresolver stamps onto the network +// event — matches any of the profile's ingress-or-egress peer selectors. +// +// Matching on the peer's identity (namespace + labels) rather than its IP is the +// whole point: it is stable across pod IP churn AND works across nodes, because +// kubeipresolver resolves the peer against a cluster-wide pod inventory before +// the event ever reaches CEL. There is deliberately no IP→pod lookup here — that +// would reintroduce a dependency on node-agent's node-local pod cache, which is +// exactly what breaks cross-node peers. +func (l *containerProfileNetworkLibrary) wasSelectorIn(containerID, namespace, podLabels ref.Val, ingress bool) ref.Val { + if l.objectCache == nil { + return types.NewErr("objectCache is nil") + } + containerIDStr, ok := containerID.Value().(string) + if !ok { + return types.MaybeNoSuchOverloadErr(containerID) + } + nsStr, ok := namespace.Value().(string) + if !ok { + return types.MaybeNoSuchOverloadErr(namespace) + } + if nsStr == "" { + // The peer did not resolve to a pod (external IP, or the resolver had no + // inventory entry): it cannot satisfy any selector. A resolved pod with + // zero labels is NOT this case - an empty podSelector may still match it. + return types.Bool(false) + } + peerLabels := refValToStringMap(podLabels) + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) + if err != nil { + return cache.NewProfileNotAvailableErr("%v", err) + } + peers := cp.EgressPeers + if ingress { + peers = cp.IngressPeers + } + if len(peers) == 0 { + return types.Bool(false) + } + return types.Bool(wasSelectorInPeers(peers, labels.Set(peerLabels), nsStr, cp.Namespace)) +} + +// refValToStringMap converts a CEL map argument to a Go map[string]string. A nil +// or non-map value yields nil (treated as "peer has no labels"). +func refValToStringMap(v ref.Val) map[string]string { + if v == nil { + return nil + } + native, err := v.ConvertToNative(reflect.TypeOf(map[string]string(nil))) + if err != nil { + return nil + } + m, _ := native.(map[string]string) + return m } diff --git a/pkg/rulemanager/cel/libraries/containerprofilenetwork/network_test.go b/pkg/rulemanager/cel/libraries/containerprofilenetwork/network_test.go index 10321073cc..5446f63a88 100644 --- a/pkg/rulemanager/cel/libraries/containerprofilenetwork/network_test.go +++ b/pkg/rulemanager/cel/libraries/containerprofilenetwork/network_test.go @@ -99,22 +99,20 @@ func TestWasAddressPortProtocolInEgress(t *testing.T) { expectedResult: true, }, { - // v1 degradation: port/protocol projection is out of scope; address-only matching. name: "Invalid port", containerID: "test-container-id", address: "192.168.1.100", port: 9999, protocol: "TCP", - expectedResult: true, + expectedResult: false, }, { - // v1 degradation: port/protocol projection is out of scope; address-only matching. name: "Invalid protocol", containerID: "test-container-id", address: "192.168.1.100", port: 80, protocol: "UDP", - expectedResult: true, + expectedResult: false, }, { name: "Invalid address", @@ -235,22 +233,20 @@ func TestWasAddressPortProtocolInIngress(t *testing.T) { expectedResult: true, }, { - // v1 degradation: port/protocol projection is out of scope; address-only matching. name: "Invalid port", containerID: "test-container-id", address: "172.16.0.10", port: 9999, protocol: "TCP", - expectedResult: true, + expectedResult: false, }, { - // v1 degradation: port/protocol projection is out of scope; address-only matching. name: "Invalid protocol", containerID: "test-container-id", address: "172.16.0.10", port: 8080, protocol: "UDP", - expectedResult: true, + expectedResult: false, }, { name: "Invalid address", @@ -405,7 +401,7 @@ func TestWasAddressPortProtocolWithNilPort(t *testing.T) { functionCache: cache.NewFunctionCache(cache.DefaultFunctionCacheConfig()), } - // v1 degradation: address-only matching; nil port in profile no longer checked. + // nil port in a profile entry = any-port wildcard for that entry's addresses. result := lib.wasAddressPortProtocolInEgress( types.String("test-container-id"), types.String("192.168.1.100"), diff --git a/pkg/rulemanager/cel/libraries/containerprofilenetwork/port_protocol_test.go b/pkg/rulemanager/cel/libraries/containerprofilenetwork/port_protocol_test.go new file mode 100644 index 0000000000..1750ac385c --- /dev/null +++ b/pkg/rulemanager/cel/libraries/containerprofilenetwork/port_protocol_test.go @@ -0,0 +1,47 @@ +package containerprofilenetwork + +import ( + "testing" + + "github.com/google/cel-go/common/types" + "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/assert" + "k8s.io/utils/ptr" +) + +func port(proto string, p int32) v1beta1.NetworkPort { + return v1beta1.NetworkPort{Protocol: v1beta1.Protocol(proto), Port: ptr.To(p)} +} + +func evalEgressPort(lib *containerProfileNetworkLibrary, addr string, p int64, proto string) types.Bool { + res := lib.wasAddressPortProtocolInEgress(types.String("cid"), types.String(addr), types.Int(p), types.String(proto)) + return cache.ConvertProfileNotAvailableErrToBool(res, false).(types.Bool) +} + +func evalIngressPort(lib *containerProfileNetworkLibrary, addr string, p int64, proto string) types.Bool { + res := lib.wasAddressPortProtocolInIngress(types.String("cid"), types.String(addr), types.Int(p), types.String(proto)) + return cache.ConvertProfileNotAvailableErrToBool(res, false).(types.Bool) +} + +func TestWasAddressPortProtocolInEgress_PortWildcard(t *testing.T) { + noPorts := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"93.184.216.34"}}, + }, nil) + assert.Equal(t, types.Bool(true), evalEgressPort(noPorts, "93.184.216.34", 8080, "TCP")) + assert.Equal(t, types.Bool(false), evalEgressPort(noPorts, "1.1.1.1", 8080, "TCP")) + + zeroPort := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"93.184.216.34"}, Ports: []v1beta1.NetworkPort{port("TCP", 0)}}, + }, nil) + assert.Equal(t, types.Bool(true), evalEgressPort(zeroPort, "93.184.216.34", 8080, "TCP")) +} + +func TestWasAddressPortProtocolInIngress_Symmetric(t *testing.T) { + lib := buildLibWithContainer(t, nil, []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"172.16.0.0/12"}, Ports: []v1beta1.NetworkPort{port("TCP", 6379)}}, + }) + assert.Equal(t, types.Bool(true), evalIngressPort(lib, "172.16.5.9", 6379, "TCP")) + assert.Equal(t, types.Bool(false), evalIngressPort(lib, "172.16.5.9", 5432, "TCP")) + assert.Equal(t, types.Bool(false), evalIngressPort(lib, "10.0.0.1", 6379, "TCP")) +} diff --git a/pkg/rulemanager/cel/libraries/containerprofilenetwork/selector_test.go b/pkg/rulemanager/cel/libraries/containerprofilenetwork/selector_test.go new file mode 100644 index 0000000000..e80efce383 --- /dev/null +++ b/pkg/rulemanager/cel/libraries/containerprofilenetwork/selector_test.go @@ -0,0 +1,63 @@ +package containerprofilenetwork + +import ( + "testing" + + "github.com/kubescape/node-agent/pkg/objectcache" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" +) + +func peer(pod, ns map[string]string) objectcache.PeerSelector { + p := objectcache.PeerSelector{PodSelector: &metav1.LabelSelector{MatchLabels: pod}} + if ns != nil { + p.NamespaceSelector = &metav1.LabelSelector{MatchLabels: ns} + } + return p +} + +func TestWasSelectorInPeers(t *testing.T) { + // Peer identity as IG's kubeipresolver stamps it onto the event: a namespace + // and pod labels, resolved cluster-wide. No IP, no local pod lookup. + podLabels := labels.Set{"app": "redis-client"} + ns := "redis" + profileNs := "redis" + nsRedis := map[string]string{"kubernetes.io/metadata.name": "redis"} + + cases := []struct { + name string + peers []objectcache.PeerSelector + peerNs string + want bool + }{ + {"label+ns match", []objectcache.PeerSelector{peer(map[string]string{"app": "redis-client"}, nsRedis)}, ns, true}, + {"label mismatch", []objectcache.PeerSelector{peer(map[string]string{"app": "other"}, nsRedis)}, ns, false}, + {"ns mismatch", []objectcache.PeerSelector{peer(map[string]string{"app": "redis-client"}, map[string]string{"kubernetes.io/metadata.name": "other"})}, ns, false}, + {"nil ns selector matches the profile namespace", []objectcache.PeerSelector{peer(map[string]string{"app": "redis-client"}, nil)}, ns, true}, + {"nil ns selector rejects a foreign namespace", []objectcache.PeerSelector{peer(map[string]string{"app": "redis-client"}, nil)}, "attacker", false}, + {"empty peers", nil, ns, false}, + {"one of several matches", []objectcache.PeerSelector{peer(map[string]string{"app": "x"}, nil), peer(map[string]string{"app": "redis-client"}, nil)}, ns, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := wasSelectorInPeers(tc.peers, podLabels, tc.peerNs, profileNs); got != tc.want { + t.Fatalf("wasSelectorInPeers = %v, want %v", got, tc.want) + } + }) + } +} + +func TestWasSelectorInPeers_EmptySelectorAndEmptyLabels(t *testing.T) { + profileNs := "redis" + emptySelector := []objectcache.PeerSelector{{PodSelector: &metav1.LabelSelector{}}} + + if !wasSelectorInPeers(emptySelector, labels.Set{}, "redis", profileNs) { + t.Fatal("an empty podSelector must match a resolved label-less pod in the profile namespace (NetworkPolicyPeer semantics)") + } + if wasSelectorInPeers(emptySelector, labels.Set{}, "attacker", profileNs) { + t.Fatal("an empty podSelector with nil namespaceSelector must not match a pod outside the profile namespace") + } + if !wasSelectorInPeers(emptySelector, labels.Set{"app": "anything"}, "redis", profileNs) { + t.Fatal("an empty podSelector selects all pods in the namespace, labelled or not") + } +} diff --git a/pkg/rulemanager/cel/libraries/containerprofilenetwork/wildcard_test.go b/pkg/rulemanager/cel/libraries/containerprofilenetwork/wildcard_test.go index e0a16c2299..bca5171f51 100644 --- a/pkg/rulemanager/cel/libraries/containerprofilenetwork/wildcard_test.go +++ b/pkg/rulemanager/cel/libraries/containerprofilenetwork/wildcard_test.go @@ -272,20 +272,16 @@ func TestWasAddressPortProtocolInEgress_PortWrapRejected(t *testing.T) { }, }, nil) - // See TestWasAddressPortProtocolInEgress_WithCIDR for the - // port/protocol regression note. The port-range guard ([0, 65535]) - // still applies — what's gone is port-specific matching: any in-range - // port matches if the address matches. cases := []struct { name string port int64 want bool }{ {"in-range hit", 443, true}, - {"in-range miss", 444, true}, // was: false (port mismatch). Now matches: address-only after projection-v1. - {"wrap-to-443 rejected", 4294967739, false}, // (1<<32)+443 — range guard fires - {"negative rejected", -1, false}, // range guard fires - {"too-large rejected", 65536, false}, // range guard fires + {"in-range miss", 444, false}, + {"wrap-to-443 rejected", 4294967739, false}, + {"negative rejected", -1, false}, + {"too-large rejected", 65536, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -350,27 +346,17 @@ func TestWasAddressPortProtocolInEgress_WithCIDR(t *testing.T) { }, }, nil) - // NOTE: upstream's projection-v1 (PR #799) explicitly drops port/protocol - // granularity from the address surface — the comment in network.go reads - // "port/protocol projection (AddressPortsByAddr) is out of scope for v1; - // degrade to address-only matching". So the matcher now only checks IP. - // - // Spec §4.7 still says ports[] is per-neighbor; the runtime gap is a - // known limitation flagged in the rebase commit. Test expectations - // updated to match runtime reality. Bringing port/protocol back is a - // follow-up: would need projection_apply to surface a per-address - // (port, protocol) set into ProjectedContainerProfile and the CEL - // helper to consult it. cases := []struct { observed string port int64 proto string want bool }{ - {"10.1.2.3", 443, "TCP", true}, // CIDR match (port/proto not enforced) - {"10.1.2.3", 80, "TCP", true}, // was: wrong port — now matches address-only - {"10.1.2.3", 443, "UDP", true}, // was: wrong protocol — now matches address-only - {"11.0.0.1", 443, "TCP", false}, // outside CIDR — still rejected + {"10.1.2.3", 443, "TCP", true}, + {"10.1.2.3", 80, "TCP", false}, + {"10.1.2.3", 443, "UDP", false}, + {"11.0.0.1", 443, "TCP", false}, + {"10.1.2.3", 443, "tcp", true}, } for _, tc := range cases { t.Run(tc.observed, func(t *testing.T) { diff --git a/pkg/rulemanager/cel/selector_compile_test.go b/pkg/rulemanager/cel/selector_compile_test.go new file mode 100644 index 0000000000..fbcb225d5b --- /dev/null +++ b/pkg/rulemanager/cel/selector_compile_test.go @@ -0,0 +1,41 @@ +package cel + +import ( + "testing" + "time" + + "github.com/goradd/maps" + "github.com/kubescape/node-agent/pkg/config" + "github.com/kubescape/node-agent/pkg/objectcache" + objectcachev1 "github.com/kubescape/node-agent/pkg/objectcache/v1" + "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache" +) + +// TestCompileSelectorRules pins that the selector rules type-check against the +// real event object type: event.dstPodLabels is declared as a generic CEL map +// and must remain assignable to the was_selector_in_{ingress,egress} map param. +// Regression guard for the R0012 ingress rule that consumes the IG-enriched +// peer namespace + labels. +func TestCompileSelectorRules(t *testing.T) { + objCache := &objectcachev1.RuleObjectCacheMock{ + ContainerIDToSharedData: maps.NewSafeMap[string, *objectcache.WatchedContainerData](), + } + c, err := NewCEL(objCache, config.Config{ + CelConfigCache: cache.FunctionCacheConfig{MaxSize: 1000, TTL: time.Minute}, + }) + if err != nil { + t.Fatalf("NewCEL: %v", err) + } + + exprs := []string{ + `cp.was_selector_in_ingress(event.containerId, event.dstNamespace, event.dstPodLabels)`, + `cp.was_selector_in_egress(event.containerId, event.dstNamespace, event.dstPodLabels)`, + // The full R0012 ingress expression as bound in default-rules.yaml. + `event.pktType == 'HOST' && !event.dstAddr.startsWith('127.') && !cp.was_address_in_ingress(event.containerId, event.dstAddr) && !cp.was_selector_in_ingress(event.containerId, event.dstNamespace, event.dstPodLabels)`, + } + for _, e := range exprs { + if err := c.registerExpression(e); err != nil { + t.Fatalf("expression failed to compile: %q\n%v", e, err) + } + } +} diff --git a/pkg/utils/cel.go b/pkg/utils/cel.go index 39c3ad40f2..b95cc1c115 100644 --- a/pkg/utils/cel.go +++ b/pkg/utils/cel.go @@ -202,6 +202,35 @@ var CelFields = map[string]*celtypes.FieldType{ return celtypes.Int(x.Raw.GetDstPort()), nil }), }, + // dstNamespace / dstPodLabels carry the peer identity that IG's + // kubeipresolver resolves cluster-wide (independent of node-agent's + // node-local pod cache), so selector rules can match a peer on any node. + "dstNamespace": { + Type: celtypes.StringType, + IsSet: isSet, + GetFrom: ref.FieldGetter(func(target any) (any, error) { + x := target.(*xcel.Object[CelEvent]) + if x.Raw == nil { + return nil, errCelObjectNil + } + return celtypes.String(x.Raw.GetDstEndpoint().Namespace), nil + }), + }, + "dstPodLabels": { + Type: celtypes.MapType, + IsSet: isSet, + GetFrom: ref.FieldGetter(func(target any) (any, error) { + x := target.(*xcel.Object[CelEvent]) + if x.Raw == nil { + return nil, errCelObjectNil + } + pl := x.Raw.GetDstEndpoint().PodLabels + if pl == nil { + pl = map[string]string{} + } + return pl, nil + }), + }, "exepath": { Type: celtypes.StringType, IsSet: isSet, diff --git a/tests/chart/templates/node-agent/default-rules.yaml b/tests/chart/templates/node-agent/default-rules.yaml index 512b4d9ec8..d64cc0c044 100644 --- a/tests/chart/templates/node-agent/default-rules.yaml +++ b/tests/chart/templates/node-agent/default-rules.yaml @@ -313,7 +313,7 @@ spec: uniqueId: "event.dstAddr + '_' + string(event.dstPort) + '_' + event.proto" ruleExpression: - eventType: "network" - expression: "event.pktType == 'OUTGOING' && !net.is_private_ip(event.dstAddr) && !cp.was_address_in_egress(event.containerId, event.dstAddr)" + expression: "event.pktType == 'OUTGOING' && !event.dstAddr.startsWith('127.') && !cp.was_address_port_protocol_in_egress(event.containerId, event.dstAddr, event.dstPort, event.proto)" profileDependency: 0 profileDataRequired: egressAddresses: all diff --git a/tests/component_test.go b/tests/component_test.go index 154e441e8b..7514071faf 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -1192,6 +1192,19 @@ func Test_21_AlertOnPartialThenLearnNetworkTest(t *testing.T) { fusioncoreIP = "162.0.217.171" ) port80 := int32(80) + port53 := int32(53) + // R0011 excludes only loopback (maximally noisy by design), so authored + // profiles must allow the pod's own DNS egress to cluster DNS or every + // nslookup mints an R0011 that skews the before/after counts. + clusterDNS := v1beta1.NetworkNeighbor{ + Identifier: "cluster-dns", + Type: v1beta1.CommunicationTypeEgress, + IPAddresses: []string{"10.96.0.0/12"}, + Ports: []v1beta1.NetworkPort{ + {Name: "UDP-53", Protocol: v1beta1.ProtocolUDP, Port: &port53}, + {Name: "TCP-53", Protocol: v1beta1.ProtocolTCP, Port: &port53}, + }, + } ns := testutils.NewRandomNamespace() k8sClient := k8sinterface.NewKubernetesApi() @@ -1220,6 +1233,7 @@ func Test_21_AlertOnPartialThenLearnNetworkTest(t *testing.T) { IPAddress: fusioncoreIP, Ports: []v1beta1.NetworkPort{{Name: "TCP-80", Protocol: v1beta1.ProtocolTCP, Port: &port80}}, }, + clusterDNS, }, }, } @@ -1291,6 +1305,7 @@ func Test_21_AlertOnPartialThenLearnNetworkTest(t *testing.T) { IPAddress: subjectIP, Ports: []v1beta1.NetworkPort{{Name: "TCP-80", Protocol: v1beta1.ProtocolTCP, Port: &port80}}, }, + clusterDNS, } _, err = storageClient.ContainerProfiles(ns.Name).Update(context.Background(), cur, metav1.UpdateOptions{}) require.NoError(t, err, "update CP: add subject IP, remove canary domain") @@ -2718,6 +2733,51 @@ func Test_28_UserDefinedNetworkNeighborhood(t *testing.T) { "fusioncore.ai IP is in NN — should NOT fire R0011") }) + // 162.0.217.171 is allowed on TCP/80 only; :443 is a port violation → R0011. + t.Run("port_violation_different_port_R0011", func(t *testing.T) { + wl := setup(t) + stdout, stderr, err := wl.ExecIntoPod([]string{"curl", "-sm5", "-k", "https://162.0.217.171"}, "curl") + t.Logf("curl https://162.0.217.171 → err=%v stdout=%q stderr=%q", err, stdout, stderr) + alerts := waitAlerts(t, wl.Namespace) + logAlerts(t, alerts) + assert.GreaterOrEqual(t, countByRule(alerts, "R0011"), 1, + "egress to allowed IP 162.0.217.171 on non-allowed port 443 must fire R0011") + }) + + // 9.9.9.9 is allowlisted with port 0 (ANY); no port fires R0011. + t.Run("port_wildcard_zero_allows_any", func(t *testing.T) { + wl := setup(t) + wl.ExecIntoPod([]string{"curl", "-sm5", "http://9.9.9.9"}, "curl") + wl.ExecIntoPod([]string{"curl", "-sm5", "-k", "https://9.9.9.9"}, "curl") + alerts := waitAlerts(t, wl.Namespace) + logAlerts(t, alerts) + assert.Equal(t, 0, countByRule(alerts, "R0011"), + "9.9.9.9 allowlisted on port 0 (any) must not fire R0011 on any port") + }) + + // 208.67.222.222 is allowlisted with no ports stanza (ANY); no port fires R0011. + t.Run("port_wildcard_empty_stanza_allows_any", func(t *testing.T) { + wl := setup(t) + wl.ExecIntoPod([]string{"curl", "-sm5", "http://208.67.222.222"}, "curl") + wl.ExecIntoPod([]string{"curl", "-sm5", "-k", "https://208.67.222.222"}, "curl") + alerts := waitAlerts(t, wl.Namespace) + logAlerts(t, alerts) + assert.Equal(t, 0, countByRule(alerts, "R0011"), + "208.67.222.222 allowlisted with empty ports stanza (any) must not fire R0011 on any port") + }) + + // Internal peer 10.96.0.1 (kube-api) is allowlisted on TCP/443 only; :80 is a port violation → R0011. + t.Run("internal_port_violation_R0011", func(t *testing.T) { + wl := setup(t) + wl.ExecIntoPod([]string{"curl", "-sm5", "-k", "https://10.96.0.1"}, "curl") + stdout, stderr, err := wl.ExecIntoPod([]string{"curl", "-sm5", "http://10.96.0.1"}, "curl") + t.Logf("curl http://10.96.0.1:80 → err=%v stdout=%q stderr=%q", err, stdout, stderr) + alerts := waitAlerts(t, wl.Namespace) + logAlerts(t, alerts) + assert.GreaterOrEqual(t, countByRule(alerts, "R0011"), 1, + "egress to internal IP 10.96.0.1 on non-allowed port 80 must fire R0011") + }) + // --------------------------------------------------------------- // 28b. Unknown domains — domains NOT in the NN → R0005. // Uses both nslookup (pure DNS) and curl (DNS + TCP). diff --git a/tests/resources/containerprofile-user-defined-network.yaml b/tests/resources/containerprofile-user-defined-network.yaml index f2f6edda1c..94e7928940 100644 --- a/tests/resources/containerprofile-user-defined-network.yaml +++ b/tests/resources/containerprofile-user-defined-network.yaml @@ -58,3 +58,36 @@ spec: - name: TCP-80 protocol: TCP port: 80 + # R0011 excludes only loopback (maximally noisy by design): allow the pod's + # own DNS egress to cluster DNS or every nslookup/curl resolution mints R0011. + - identifier: cluster-dns + type: internal + ipAddresses: + - 10.96.0.0/12 + ports: + - name: UDP-53 + protocol: UDP + port: 53 + - name: TCP-53 + protocol: TCP + port: 53 + - identifier: wildcard-zero-port + type: external + ipAddress: 9.9.9.9 + ports: + - name: TCP-any + protocol: TCP + port: 0 + - identifier: wildcard-empty-ports + type: external + ipAddress: 208.67.222.222 + - identifier: cluster-dns + type: internal + ipAddress: 10.96.0.10 + - identifier: kube-api + type: internal + ipAddress: 10.96.0.1 + ports: + - name: TCP-443 + protocol: TCP + port: 443 diff --git a/tests/resources/network_fixture_lint_test.go b/tests/resources/network_fixture_lint_test.go index a7ec6cd0e2..5efacdcf5a 100644 --- a/tests/resources/network_fixture_lint_test.go +++ b/tests/resources/network_fixture_lint_test.go @@ -205,8 +205,9 @@ func lintEndpoint(dir string, e netEndpoint, add func(rule, msg string)) { if p.Protocol != "TCP" && p.Protocol != "UDP" { add("R-NN-20", where(fmt.Sprintf("port %q protocol %q is not TCP|UDP", p.Name, p.Protocol))) } - if p.Port < 1 || p.Port > 65535 { - add("R-NN-20", where(fmt.Sprintf("port %q value %d out of range 1..65535", p.Name, p.Port))) + // Port 0 is the any-port wildcard (matches R0011/R0012 port semantics). + if p.Port != 0 && (p.Port < 1 || p.Port > 65535) { + add("R-NN-20", where(fmt.Sprintf("port %q value %d out of range 1..65535 (0 = any)", p.Name, p.Port))) } } } diff --git a/tests/scripts/issue79-eol-ladder.sh b/tests/scripts/issue79-eol-ladder.sh new file mode 100755 index 0000000000..66c3ec9ea3 --- /dev/null +++ b/tests/scripts/issue79-eol-ladder.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# issue79-eol-ladder.sh — E2E ladder for exec-event delivery at container +# end-of-life (issue #79, acceptance tests T4/T5). +# +# Measures, over N iterations on a live cluster running the kubescape stack: +# T4: the forbidden terminal exec of the init container `setup` +# (sh -c "sleep ; /usr/bin/id") produces an R0001 alert — N/N. +# T5: the forbidden terminal execs of an ephemeral container `debug` +# (sh -c "sleep ; /usr/bin/whoami; /usr/bin/id") produce R0001 — +# N/N. +# +# Prerequisites: +# - kubectl context pointing at the test cluster +# - kubescape stack deployed (node-agent image under test), namespace +# `kubescape` +# - fixtures from tests/resources: mc37-cp-doc.yaml (grouped profile: +# app allows id; setup forbids id; debug forbids id/whoami) +# +# Usage: issue79-eol-ladder.sh [ITERATIONS] [RUNWAY_SECONDS] +set -euo pipefail + +ITERATIONS="${1:-5}" +RUNWAY="${2:-30}" +NS="node-agent-test-eol" +KS_NS="kubescape" +FIXTURE_DIR="$(cd "$(dirname "$0")/../resources" && pwd)" + +t4_pass=0 +t5_pass=0 + +log() { echo "[$(date -u +%H:%M:%S)] $*"; } + +node_agent_pod() { + kubectl -n "$KS_NS" get pods -l app.kubernetes.io/name=node-agent \ + -o jsonpath='{.items[0].metadata.name}' +} + +# Count R0001 alerts for a container name in node-agent logs since a given +# RFC3339 timestamp. +count_r0001() { + # Read EVERY node-agent pod (DaemonSet - the workload may land on any node) + # and match the alert JSON's containerName field explicitly. + local container="$1" since="$2" total=0 n + for pod in $(kubectl -n "$KS_NS" get pods -o name | grep node-agent); do + n=$(kubectl -n "$KS_NS" logs "${pod#pod/}" -c node-agent --since-time="$since" 2>/dev/null \ + | grep '"RuleID":"R0001"' | grep -c "\"containerName\":\"${container}\"" || true) + total=$((total + n)) + done + echo "$total" +} + +kubectl get ns "$NS" >/dev/null 2>&1 || kubectl create ns "$NS" +kubectl -n "$NS" apply -f "$FIXTURE_DIR/mc37-cp-doc.yaml" + +for i in $(seq 1 "$ITERATIONS"); do + log "=== iteration $i/$ITERATIONS (runway ${RUNWAY}s) ===" + kubectl -n "$NS" delete deployment mc37-deployment --ignore-not-found --wait + sleep 3 + iter_start="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + # Deploy with the requested init runway. + sed "s/sleep 100/sleep ${RUNWAY}/" \ + "$FIXTURE_DIR/mc37-multi-subtype-userdefined-deployment.yaml" \ + | kubectl -n "$NS" apply -f - + + # Wait for the pod: init phase (runway) + margin. + log "waiting for pod Ready (init runway ${RUNWAY}s)..." + kubectl -n "$NS" rollout status deploy/mc37-deployment --timeout="$((RUNWAY + 150))s" + pod="$(kubectl -n "$NS" get pod -l app=mc37 -o jsonpath='{.items[0].metadata.name}')" + + # T4: the init terminal exec happened just before the pod became Ready. + # Give the pipeline a moment, then count. + sleep 10 + init_r0001="$(count_r0001 setup "$iter_start")" + if [ "${init_r0001:-0}" -gt 0 ]; then + t4_pass=$((t4_pass + 1)); log "T4 init: PASS (R0001 setup=${init_r0001})" + else + log "T4 init: FAIL (R0001 setup=0)" + fi + + # T5: attach ephemeral container with a terminal forbidden exec. + eph_start="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + eph_runway=20 + kubectl -n "$NS" debug "$pod" --image=debian:12-slim --container=debug \ + --profile=general -- sh -c "sleep ${eph_runway}; /usr/bin/whoami; /usr/bin/id" \ + >/dev/null + log "waiting for ephemeral container debug to terminate..." + for _ in $(seq 1 $((eph_runway + 60))); do + state="$(kubectl -n "$NS" get pod "$pod" \ + -o jsonpath='{.status.ephemeralContainerStatuses[?(@.name=="debug")].state.terminated.exitCode}' 2>/dev/null || true)" + [ -n "$state" ] && break + sleep 2 + done + sleep 10 + eph_r0001="$(count_r0001 debug "$eph_start")" + if [ "${eph_r0001:-0}" -gt 0 ]; then + t5_pass=$((t5_pass + 1)); log "T5 ephemeral: PASS (R0001 debug=${eph_r0001})" + else + log "T5 ephemeral: FAIL (R0001 debug=0)" + fi +done + +echo +echo "==== issue #79 EOL ladder result ====" +echo "T4 (init terminal exec R0001): ${t4_pass}/${ITERATIONS}" +echo "T5 (ephemeral terminal exec R0001): ${t5_pass}/${ITERATIONS}" +[ "$t4_pass" -eq "$ITERATIONS" ] && [ "$t5_pass" -eq "$ITERATIONS" ] From 456d267f5892cf4686514a8769c25d731ca3e1e3 Mon Sep 17 00:00:00 2001 From: entlein Date: Tue, 18 Aug 2026 19:08:27 +0200 Subject: [PATCH 2/3] Allow alert from unexpected Ports, allow Port=0 as intentional wildcard Signed-off-by: entlein --- tests/scripts/issue79-eol-ladder.sh | 107 ---------------------------- 1 file changed, 107 deletions(-) delete mode 100755 tests/scripts/issue79-eol-ladder.sh diff --git a/tests/scripts/issue79-eol-ladder.sh b/tests/scripts/issue79-eol-ladder.sh deleted file mode 100755 index 66c3ec9ea3..0000000000 --- a/tests/scripts/issue79-eol-ladder.sh +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env bash -# issue79-eol-ladder.sh — E2E ladder for exec-event delivery at container -# end-of-life (issue #79, acceptance tests T4/T5). -# -# Measures, over N iterations on a live cluster running the kubescape stack: -# T4: the forbidden terminal exec of the init container `setup` -# (sh -c "sleep ; /usr/bin/id") produces an R0001 alert — N/N. -# T5: the forbidden terminal execs of an ephemeral container `debug` -# (sh -c "sleep ; /usr/bin/whoami; /usr/bin/id") produce R0001 — -# N/N. -# -# Prerequisites: -# - kubectl context pointing at the test cluster -# - kubescape stack deployed (node-agent image under test), namespace -# `kubescape` -# - fixtures from tests/resources: mc37-cp-doc.yaml (grouped profile: -# app allows id; setup forbids id; debug forbids id/whoami) -# -# Usage: issue79-eol-ladder.sh [ITERATIONS] [RUNWAY_SECONDS] -set -euo pipefail - -ITERATIONS="${1:-5}" -RUNWAY="${2:-30}" -NS="node-agent-test-eol" -KS_NS="kubescape" -FIXTURE_DIR="$(cd "$(dirname "$0")/../resources" && pwd)" - -t4_pass=0 -t5_pass=0 - -log() { echo "[$(date -u +%H:%M:%S)] $*"; } - -node_agent_pod() { - kubectl -n "$KS_NS" get pods -l app.kubernetes.io/name=node-agent \ - -o jsonpath='{.items[0].metadata.name}' -} - -# Count R0001 alerts for a container name in node-agent logs since a given -# RFC3339 timestamp. -count_r0001() { - # Read EVERY node-agent pod (DaemonSet - the workload may land on any node) - # and match the alert JSON's containerName field explicitly. - local container="$1" since="$2" total=0 n - for pod in $(kubectl -n "$KS_NS" get pods -o name | grep node-agent); do - n=$(kubectl -n "$KS_NS" logs "${pod#pod/}" -c node-agent --since-time="$since" 2>/dev/null \ - | grep '"RuleID":"R0001"' | grep -c "\"containerName\":\"${container}\"" || true) - total=$((total + n)) - done - echo "$total" -} - -kubectl get ns "$NS" >/dev/null 2>&1 || kubectl create ns "$NS" -kubectl -n "$NS" apply -f "$FIXTURE_DIR/mc37-cp-doc.yaml" - -for i in $(seq 1 "$ITERATIONS"); do - log "=== iteration $i/$ITERATIONS (runway ${RUNWAY}s) ===" - kubectl -n "$NS" delete deployment mc37-deployment --ignore-not-found --wait - sleep 3 - iter_start="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - - # Deploy with the requested init runway. - sed "s/sleep 100/sleep ${RUNWAY}/" \ - "$FIXTURE_DIR/mc37-multi-subtype-userdefined-deployment.yaml" \ - | kubectl -n "$NS" apply -f - - - # Wait for the pod: init phase (runway) + margin. - log "waiting for pod Ready (init runway ${RUNWAY}s)..." - kubectl -n "$NS" rollout status deploy/mc37-deployment --timeout="$((RUNWAY + 150))s" - pod="$(kubectl -n "$NS" get pod -l app=mc37 -o jsonpath='{.items[0].metadata.name}')" - - # T4: the init terminal exec happened just before the pod became Ready. - # Give the pipeline a moment, then count. - sleep 10 - init_r0001="$(count_r0001 setup "$iter_start")" - if [ "${init_r0001:-0}" -gt 0 ]; then - t4_pass=$((t4_pass + 1)); log "T4 init: PASS (R0001 setup=${init_r0001})" - else - log "T4 init: FAIL (R0001 setup=0)" - fi - - # T5: attach ephemeral container with a terminal forbidden exec. - eph_start="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - eph_runway=20 - kubectl -n "$NS" debug "$pod" --image=debian:12-slim --container=debug \ - --profile=general -- sh -c "sleep ${eph_runway}; /usr/bin/whoami; /usr/bin/id" \ - >/dev/null - log "waiting for ephemeral container debug to terminate..." - for _ in $(seq 1 $((eph_runway + 60))); do - state="$(kubectl -n "$NS" get pod "$pod" \ - -o jsonpath='{.status.ephemeralContainerStatuses[?(@.name=="debug")].state.terminated.exitCode}' 2>/dev/null || true)" - [ -n "$state" ] && break - sleep 2 - done - sleep 10 - eph_r0001="$(count_r0001 debug "$eph_start")" - if [ "${eph_r0001:-0}" -gt 0 ]; then - t5_pass=$((t5_pass + 1)); log "T5 ephemeral: PASS (R0001 debug=${eph_r0001})" - else - log "T5 ephemeral: FAIL (R0001 debug=0)" - fi -done - -echo -echo "==== issue #79 EOL ladder result ====" -echo "T4 (init terminal exec R0001): ${t4_pass}/${ITERATIONS}" -echo "T5 (ephemeral terminal exec R0001): ${t5_pass}/${ITERATIONS}" -[ "$t4_pass" -eq "$ITERATIONS" ] && [ "$t5_pass" -eq "$ITERATIONS" ] From ffb22be5cbbe35a1059e59b9789f484462cc7612 Mon Sep 17 00:00:00 2001 From: entlein Date: Thu, 20 Aug 2026 10:57:47 +0200 Subject: [PATCH 3/3] remove explicit wildcard, declare port as non-mandatory, keep the alert if delcared and violated Signed-off-by: entlein --- pkg/objectcache/addr_ports_test.go | 43 +++++++++++++++++++ pkg/objectcache/projection_types.go | 13 +++--- .../containerprofilenetwork/network.go | 4 +- .../containerprofilenetwork/network_test.go | 7 +-- .../port_protocol_test.go | 29 ++++++++++++- tests/component_test.go | 9 ++-- 6 files changed, 90 insertions(+), 15 deletions(-) create mode 100644 pkg/objectcache/addr_ports_test.go diff --git a/pkg/objectcache/addr_ports_test.go b/pkg/objectcache/addr_ports_test.go new file mode 100644 index 0000000000..d1dd2ee88d --- /dev/null +++ b/pkg/objectcache/addr_ports_test.go @@ -0,0 +1,43 @@ +package objectcache + +import ( + "testing" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/assert" + "k8s.io/utils/ptr" +) + +func np(proto string, p int32) v1beta1.NetworkPort { + return v1beta1.NetworkPort{Protocol: v1beta1.Protocol(proto), Port: ptr.To(p)} +} + +func TestExtractAddrPorts_ZeroPortIsALiteralNotAWildcard(t *testing.T) { + groups := ExtractAddrPorts([]v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"10.0.5.9"}, Ports: []v1beta1.NetworkPort{np("TCP", 0), np("UDP", 53)}}, + }) + assert.Len(t, groups, 1) + assert.NotNil(t, groups[0].Ports, "a zero-port entry must not collapse the entry to fully open") + assert.Contains(t, groups[0].Ports, PortKey("TCP", 0)) + assert.Contains(t, groups[0].Ports, PortKey("UDP", 53)) + assert.Len(t, groups[0].Ports, 2) +} + +func TestExtractAddrPorts_AbsentStanzaIsTheOnlyWildcard(t *testing.T) { + groups := ExtractAddrPorts([]v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"93.184.216.34"}}, + }) + assert.Len(t, groups, 1) + assert.Nil(t, groups[0].Ports) +} + +func TestExtractAddrPorts_NilPortEntryContributesNothing(t *testing.T) { + groups := ExtractAddrPorts([]v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"10.1.2.3"}, Ports: []v1beta1.NetworkPort{{Protocol: "TCP", Port: nil}, np("UDP", 53)}}, + }) + assert.Len(t, groups, 1) + assert.NotNil(t, groups[0].Ports) + assert.NotContains(t, groups[0].Ports, PortKey("TCP", 0)) + assert.Contains(t, groups[0].Ports, PortKey("UDP", 53)) + assert.Len(t, groups[0].Ports, 1) +} diff --git a/pkg/objectcache/projection_types.go b/pkg/objectcache/projection_types.go index a8f7e4944a..4d81dd3a60 100644 --- a/pkg/objectcache/projection_types.go +++ b/pkg/objectcache/projection_types.go @@ -57,7 +57,9 @@ type FieldSpec struct { } // AddrPortGroup pairs one neighbor entry's addresses with its allowed ports. -// Empty Ports means any port (port 0 or no ports declared = wildcard). +// Ports == nil means the neighbor declared no ports stanza (indistinguishable +// from an empty one after a storage round-trip) and matches any port; a +// non-empty map matches only its literal (protocol, port) keys. type AddrPortGroup struct { Addrs []string Ports map[string]struct{} @@ -79,16 +81,17 @@ func ExtractAddrPorts(neighbors []v1beta1.NetworkNeighbor) []AddrPortGroup { if len(addrs) == 0 { continue } + // The only port wildcard is an absent (or empty — protobuf cannot tell + // them apart) ports stanza. A listed entry always restricts: an explicit + // port (0 included) is a literal, a nil port contributes nothing. ports := make(map[string]struct{}, len(n.Ports)) - wildcard := len(n.Ports) == 0 for _, p := range n.Ports { - if p.Port == nil || *p.Port == 0 { - wildcard = true + if p.Port == nil { continue } ports[PortKey(string(p.Protocol), *p.Port)] = struct{}{} } - if wildcard { + if len(n.Ports) == 0 { ports = nil } groups = append(groups, AddrPortGroup{Addrs: addrs, Ports: ports}) diff --git a/pkg/rulemanager/cel/libraries/containerprofilenetwork/network.go b/pkg/rulemanager/cel/libraries/containerprofilenetwork/network.go index ff1bbed68e..723827e982 100644 --- a/pkg/rulemanager/cel/libraries/containerprofilenetwork/network.go +++ b/pkg/rulemanager/cel/libraries/containerprofilenetwork/network.go @@ -64,7 +64,7 @@ func matchIPField(field *objectcache.ProjectedField, observed string) bool { // matchAddrPort reports whether observed (address, protocol, port) falls within // any single neighbor entry: its addresses match AND the entry allows the port -// (empty Ports = any port). Address-only entries thus stay wildcard on ports. +// (nil Ports = no ports stanza = any port; a populated map matches literal keys only). func matchAddrPort(groups []objectcache.AddrPortGroup, address, protocol string, port int32) bool { if address == "" { return false @@ -75,7 +75,7 @@ func matchAddrPort(groups []objectcache.AddrPortGroup, address, protocol string, if !networkmatch.MatchIP(g.Addrs, address) { continue } - if len(g.Ports) == 0 { + if g.Ports == nil { return true } if _, ok := g.Ports[key]; ok { diff --git a/pkg/rulemanager/cel/libraries/containerprofilenetwork/network_test.go b/pkg/rulemanager/cel/libraries/containerprofilenetwork/network_test.go index 5446f63a88..e94ac732e7 100644 --- a/pkg/rulemanager/cel/libraries/containerprofilenetwork/network_test.go +++ b/pkg/rulemanager/cel/libraries/containerprofilenetwork/network_test.go @@ -401,14 +401,15 @@ func TestWasAddressPortProtocolWithNilPort(t *testing.T) { functionCache: cache.NewFunctionCache(cache.DefaultFunctionCacheConfig()), } - // nil port in a profile entry = any-port wildcard for that entry's addresses. + // A listed entry with a nil port contributes nothing: the only port + // wildcard is an ABSENT ports stanza, so these addresses stay restricted. result := lib.wasAddressPortProtocolInEgress( types.String("test-container-id"), types.String("192.168.1.100"), types.Int(80), types.String("TCP"), ) - assert.Equal(t, types.Bool(true), result) + assert.Equal(t, types.Bool(false), result) result = lib.wasAddressPortProtocolInIngress( types.String("test-container-id"), @@ -416,5 +417,5 @@ func TestWasAddressPortProtocolWithNilPort(t *testing.T) { types.Int(8080), types.String("TCP"), ) - assert.Equal(t, types.Bool(true), result) + assert.Equal(t, types.Bool(false), result) } diff --git a/pkg/rulemanager/cel/libraries/containerprofilenetwork/port_protocol_test.go b/pkg/rulemanager/cel/libraries/containerprofilenetwork/port_protocol_test.go index 1750ac385c..20773b1b83 100644 --- a/pkg/rulemanager/cel/libraries/containerprofilenetwork/port_protocol_test.go +++ b/pkg/rulemanager/cel/libraries/containerprofilenetwork/port_protocol_test.go @@ -34,7 +34,8 @@ func TestWasAddressPortProtocolInEgress_PortWildcard(t *testing.T) { zeroPort := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ {IPAddresses: []string{"93.184.216.34"}, Ports: []v1beta1.NetworkPort{port("TCP", 0)}}, }, nil) - assert.Equal(t, types.Bool(true), evalEgressPort(zeroPort, "93.184.216.34", 8080, "TCP")) + assert.Equal(t, types.Bool(false), evalEgressPort(zeroPort, "93.184.216.34", 8080, "TCP"), + "an explicit port 0 is a literal, not a wildcard: only an absent ports stanza opens the entry") } func TestWasAddressPortProtocolInIngress_Symmetric(t *testing.T) { @@ -45,3 +46,29 @@ func TestWasAddressPortProtocolInIngress_Symmetric(t *testing.T) { assert.Equal(t, types.Bool(false), evalIngressPort(lib, "172.16.5.9", 5432, "TCP")) assert.Equal(t, types.Bool(false), evalIngressPort(lib, "10.0.0.1", 6379, "TCP")) } + +func TestWasAddressPortProtocolInEgress_ZeroPortIsNotAWildcard(t *testing.T) { + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"93.184.216.34"}, Ports: []v1beta1.NetworkPort{port("TCP", 0)}}, + }, nil) + assert.Equal(t, types.Bool(false), evalEgressPort(lib, "93.184.216.34", 8080, "TCP")) + assert.Equal(t, types.Bool(false), evalEgressPort(lib, "93.184.216.34", 53, "UDP")) +} + +func TestWasAddressPortProtocolInEgress_MixedZeroPortKeepsEveryProtocolRestricted(t *testing.T) { + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"10.0.5.9"}, Ports: []v1beta1.NetworkPort{port("TCP", 0), port("UDP", 53)}}, + }, nil) + assert.Equal(t, types.Bool(false), evalEgressPort(lib, "10.0.5.9", 9999, "TCP"), + "an explicit {TCP,0} entry no longer opens TCP: wildcard is expressed only by omitting the ports stanza") + assert.Equal(t, types.Bool(true), evalEgressPort(lib, "10.0.5.9", 53, "UDP")) + assert.Equal(t, types.Bool(false), evalEgressPort(lib, "10.0.5.9", 54, "UDP")) +} + +func TestWasAddressPortProtocolInEgress_NilPortEntryContributesNothing(t *testing.T) { + lib := buildLibWithContainer(t, []v1beta1.NetworkNeighbor{ + {IPAddresses: []string{"10.0.5.9"}, Ports: []v1beta1.NetworkPort{{Protocol: "TCP"}, port("UDP", 53)}}, + }, nil) + assert.Equal(t, types.Bool(false), evalEgressPort(lib, "10.0.5.9", 8080, "TCP")) + assert.Equal(t, types.Bool(true), evalEgressPort(lib, "10.0.5.9", 53, "UDP")) +} diff --git a/tests/component_test.go b/tests/component_test.go index 7514071faf..d88e768b33 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -2744,15 +2744,16 @@ func Test_28_UserDefinedNetworkNeighborhood(t *testing.T) { "egress to allowed IP 162.0.217.171 on non-allowed port 443 must fire R0011") }) - // 9.9.9.9 is allowlisted with port 0 (ANY); no port fires R0011. - t.Run("port_wildcard_zero_allows_any", func(t *testing.T) { + // 9.9.9.9 is listed with an explicit port 0 — a literal, NOT a wildcard: + // the only port wildcard is an absent ports stanza, so :80/:443 violate. + t.Run("port_zero_is_literal_not_wildcard", func(t *testing.T) { wl := setup(t) wl.ExecIntoPod([]string{"curl", "-sm5", "http://9.9.9.9"}, "curl") wl.ExecIntoPod([]string{"curl", "-sm5", "-k", "https://9.9.9.9"}, "curl") alerts := waitAlerts(t, wl.Namespace) logAlerts(t, alerts) - assert.Equal(t, 0, countByRule(alerts, "R0011"), - "9.9.9.9 allowlisted on port 0 (any) must not fire R0011 on any port") + assert.GreaterOrEqual(t, countByRule(alerts, "R0011"), 1, + "an explicit port-0 entry must not open 9.9.9.9 on other ports") }) // 208.67.222.222 is allowlisted with no ports stanza (ANY); no port fires R0011.