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
43 changes: 43 additions & 0 deletions pkg/objectcache/addr_ports_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
33 changes: 33 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 All @@ -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
}

Expand Down Expand Up @@ -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
}
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
75 changes: 75 additions & 0 deletions pkg/objectcache/projection_types.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -41,9 +56,57 @@ type FieldSpec struct {
SuffixMatcher PathMatcher
}

// AddrPortGroup pairs one neighbor entry's addresses with its allowed ports.
// 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{}
}

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
}
// 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))
for _, p := range n.Ports {
if p.Port == nil {
continue
Comment on lines +87 to +90

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wildcard is computed once per neighbor entry, not per protocol/port. If a single NetworkNeighbor mixes a Port: 0 entry for one protocol with specific ports for another (e.g. {TCP, 0} + {UDP, 53}), the if wildcard { ports = nil } below drops all of that entry's ports — so the address becomes open on both TCP and UDP, any port, even though UDP was explicitly restricted to 53. Worth either scoping the wildcard to its own protocol (e.g. map[string]struct{} keyed loosely, or drop only matching-protocol port entries) or documenting that mixing a Port: 0 entry with other protocols' specific ports in the same neighbor isn't supported and will fully open that address.

@entlein entlein Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very true,
The easiest solution to this might be to backtrack and treat the null as the wildcard (i.e. if the port is not specified, it is treated as ANY) . Thats however not consistent with the narrative of the profiles.

Guess, its a good point in time, to review all our settings for what they mean is absent, null, wrong, or wildcarded.

So, the base premise is: if it aint explicitely listed, it aint allowed, so it'll alert

Oh dear oh dear 🤣😅,That ll break things, so maybe, this mantra needs the extension unless its a non-mandatory field , in which case the absence of the entry means ANYTHING goes.

(I think, I just found another bug (beyond this one), darn)... THANKS FOR THE REVIEW!

EDIT: bug=logic bug, not code

}
ports[PortKey(string(p.Protocol), *p.Port)] = struct{}{}
}
if len(n.Ports) == 0 {
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
Expand All @@ -54,6 +117,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
Expand Down
3 changes: 3 additions & 0 deletions pkg/objectcache/v1/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

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
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
},
}

Expand Down
Loading
Loading