Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions pkg/objectcache/containerprofilecache/projection_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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"}}},
},
},
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@
"PrefixHits": {},
"SuffixHits": {}
},
"ingressPeers": null,
"egressPeers": null,
"execsByPath": {
"/bin/curl": [
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@
"PrefixHits": {},
"SuffixHits": {}
},
"ingressPeers": null,
"egressPeers": null,
"execsByPath": {
"/bin/curl": [
[
Expand Down
25 changes: 25 additions & 0 deletions pkg/objectcache/projection_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions pkg/rulemanager/cel/libraries/containerprofilenetwork/network.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package containerprofilenetwork

import (
"net"
"reflect"
"strings"

"github.com/google/cel-go/common/types"
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Loading
Loading