From 14a3283ae61b57d988b061bd36c5065696eaae6c Mon Sep 17 00:00:00 2001 From: entlein Date: Tue, 18 Aug 2026 17:37:39 +0200 Subject: [PATCH] allow networkpolicy to be a cel selector for internal/external traffic allowlisting Signed-off-by: entlein --- .../containerprofilecache/projection_apply.go | 30 +++++ .../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 | 25 +++++ .../containerprofilenetwork.go | 29 +++++ .../containerprofilenetwork/network.go | 105 ++++++++++++++++++ .../containerprofilenetwork/selector_test.go | 63 +++++++++++ pkg/rulemanager/cel/selector_compile_test.go | 41 +++++++ pkg/utils/cel.go | 29 +++++ 11 files changed, 365 insertions(+) create mode 100644 pkg/rulemanager/cel/libraries/containerprofilenetwork/selector_test.go create mode 100644 pkg/rulemanager/cel/selector_compile_test.go diff --git a/pkg/objectcache/containerprofilecache/projection_apply.go b/pkg/objectcache/containerprofilecache/projection_apply.go index f0de872a52..a513680164 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) @@ -260,3 +264,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..07ea313ebc 100644 --- a/pkg/objectcache/projection_types.go +++ b/pkg/objectcache/projection_types.go @@ -3,8 +3,20 @@ package objectcache import ( "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 @@ -44,6 +56,11 @@ type FieldSpec struct { // 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 +71,14 @@ 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 + // 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/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/network.go b/pkg/rulemanager/cel/libraries/containerprofilenetwork/network.go index 3d97e85f2e..f653c1b8c3 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 @@ -219,3 +222,105 @@ func (l *containerProfileNetworkLibrary) wasAddressPortProtocolInIngress(contain } return types.Bool(matchIPField(&cp.IngressAddresses, addressStr)) } + +// 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/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/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,