diff --git a/cmd/main.go b/cmd/main.go index 3a2f0fd9cf..2d1395b9c0 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -357,6 +357,15 @@ func main() { ruleBindingCache.AddNotifier(&ruleBindingNotify) cpc := containerprofilecache.NewContainerProfileCache(cfg, storageClient, k8sObjectCache, metricsProvider) + if cfg.HostMonitoringEnabled { + cpc.SetHostLabelSource(func(ctx context.Context) (map[string]string, error) { + node, err := k8sClient.GetKubernetesClient().CoreV1().Nodes().Get(ctx, cfg.NodeName, metav1.GetOptions{}) + if err != nil { + return nil, err + } + return node.Labels, nil + }) + } cpc.Start(ctx) if cpm, ok := containerProfileManager.(*containerprofilemanagerv1.ContainerProfileManager); ok { cpm.SetCompletionNotifier(cpc) diff --git a/docs/features/host-profile-binding.md b/docs/features/host-profile-binding.md new file mode 100644 index 0000000000..96c4634718 --- /dev/null +++ b/docs/features/host-profile-binding.md @@ -0,0 +1,48 @@ +# Host profile binding + +In Kubernetes, enable `hostMonitoringEnabled` and +`applicationProfileServiceEnabled` to learn the host's ContainerProfile. Enable +`runtimeDetectionEnabled` to evaluate rules against it. Profiles are stored in +the node-agent deployment namespace. + +To use an authored ContainerProfile for a host, create it in that namespace and +label the Node running the agent: + +```sh +kubectl label node kubescape.io/user-defined-profile= --overwrite +``` + +The profile cache reads the Node label at registration and on its refresh ticks. +Changing the label selects a different authored profile; removing it returns to +the learned profile. A failed Node lookup retains the last observed binding; +it does not count as label removal. The agent needs `get` permission on Nodes, +which the test chart already grants. + +Host network observations are learned for the synthetic `host` container. +Network tracing must also be enabled (`networkServiceEnabled` or +`runtimeDetectionEnabled`, with `dNetwork` disabled). +Ordinary Pods using `hostNetwork` remain excluded from network profile learning. +The test chart enables host context for R0001, R0002, R0003, R0004 and R0040. +Other rule deployments must opt those rules into `context:host` themselves. +Required-profile rules remain suppressed while their baseline is unavailable. + +## Identity and configuration compatibility + +Binding a Node label does not rename existing profiles. The shared host identity +continues to use the existing synthetic `host` namespace and +`wlid://cluster-unknown/namespace-host/host-`; the Kubernetes storage +namespace is the agent deployment namespace. See +[host monitoring completion](host-monitoring-completion.md) for the identity +builder and learning lifecycle. + +A Node identity with the actual cluster name, node artifact labels and a +hostname selector would change profile keys and downstream lookup/cleanup +contracts. Such a migration needs coordinated storage and consumer support, +plus an explicit policy for existing baselines and node replacement. It should +not be introduced by changing the builder in place. + +There is no separate `hostProfileServiceEnabled` switch. The existing host +monitoring and application profiling switches continue to control collection. +An independent host profiling switch could later provide host/container cost +control, but its default must preserve existing host learning, and disabling +learning must have defined behavior for bound profiles and required rules. diff --git a/pkg/containerprofilemanager/v1/event_reporting.go b/pkg/containerprofilemanager/v1/event_reporting.go index 9e71cb1f55..ebb4b56a40 100644 --- a/pkg/containerprofilemanager/v1/event_reporting.go +++ b/pkg/containerprofilemanager/v1/event_reporting.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/DmitriyVTitov/size" + "github.com/armosec/armoapi-go/armotypes" mapset "github.com/deckarep/golang-set/v2" "github.com/goradd/maps" "github.com/kubescape/go-logger" @@ -372,7 +373,7 @@ func (cpm *ContainerProfileManager) ReportIdentifiedCallStack(containerID string // ReportNetworkEvent reports a network event for a container func (cpm *ContainerProfileManager) ReportNetworkEvent(containerID string, event utils.NetworkEvent) { - if !cpm.isValidNetworkEvent(event) { + if !cpm.isValidNetworkEvent(containerID, event) { return } @@ -468,7 +469,7 @@ func (cpm *ContainerProfileManager) ReportSyscalls(containerID string, syscalls } // isValidNetworkEvent checks if the network event is valid for processing -func (cpm *ContainerProfileManager) isValidNetworkEvent(event utils.NetworkEvent) bool { +func (cpm *ContainerProfileManager) isValidNetworkEvent(containerID string, event utils.NetworkEvent) bool { pktType := event.GetPktType() // Unknown type, shouldn't happen if pktType != utils.HostPktType && pktType != utils.OutgoingPktType { @@ -481,8 +482,8 @@ func (cpm *ContainerProfileManager) isValidNetworkEvent(event utils.NetworkEvent return false } - // Ignore host netns - if event.GetHostNetwork() { + // Learn host traffic only in the host profile, not in profiles for hostNetwork pods. + if event.GetHostNetwork() && containerID != armotypes.HostContainerID { return false } diff --git a/pkg/containerprofilemanager/v1/host_network_test.go b/pkg/containerprofilemanager/v1/host_network_test.go new file mode 100644 index 0000000000..819985d442 --- /dev/null +++ b/pkg/containerprofilemanager/v1/host_network_test.go @@ -0,0 +1,62 @@ +package containerprofilemanager + +import ( + "testing" + + "github.com/armosec/armoapi-go/armotypes" + "github.com/inspektor-gadget/inspektor-gadget/pkg/types" + "github.com/kubescape/node-agent/pkg/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReportNetworkEventHostNetwork(t *testing.T) { + for _, tc := range []struct { + name string + containerID string + hostNetwork bool + pktType string + podHostIP string + wantStored bool + }{ + {name: "host egress", containerID: armotypes.HostContainerID, hostNetwork: true, pktType: utils.OutgoingPktType, wantStored: true}, + {name: "host ingress", containerID: armotypes.HostContainerID, hostNetwork: true, pktType: utils.HostPktType, wantStored: true}, + {name: "hostNetwork pod egress", containerID: "hostnet-pod", hostNetwork: true, pktType: utils.OutgoingPktType}, + {name: "hostNetwork pod ingress", containerID: "hostnet-pod", hostNetwork: true, pktType: utils.HostPktType}, + {name: "ordinary pod", containerID: "pod", pktType: utils.OutgoingPktType, wantStored: true}, + {name: "host unknown packet type", containerID: armotypes.HostContainerID, hostNetwork: true, pktType: "UNKNOWN"}, + {name: "host localhost ingress", containerID: armotypes.HostContainerID, hostNetwork: true, pktType: utils.HostPktType, podHostIP: "10.0.0.5"}, + } { + t.Run(tc.name, func(t *testing.T) { + cpm, entry := newTestManager(t, tc.containerID) + event := &utils.StructEvent{ + DstEndpoint: types.L3Endpoint{Addr: "10.0.0.5"}, + DstPort: 8080, + Proto: "tcp", + PktType: tc.pktType, + PodHostIP: tc.podHostIP, + HostNetwork: tc.hostNetwork, + } + + cpm.ReportNetworkEvent(tc.containerID, event) + + if !tc.wantStored { + assert.Nil(t, entry.data.networks) + assert.Zero(t, entry.data.size.Load()) + return + } + require.NotNil(t, entry.data.networks) + require.Equal(t, 1, entry.data.networks.Cardinality()) + assert.True(t, entry.data.networks.Contains(NetworkEvent{ + Port: 8080, + Protocol: "tcp", + PktType: tc.pktType, + Destination: Destination{IPAddress: "10.0.0.5"}, + })) + assert.Positive(t, entry.data.size.Load()) + + // Accepted host observations must reach the same flush set as pod observations. + assert.Equal(t, entry.data.networks, entry.data.networkEventsForFlush(false)) + }) + } +} diff --git a/pkg/containerprofilemanager/v1/host_profile_content_test.go b/pkg/containerprofilemanager/v1/host_profile_content_test.go index ba46f47915..96bb952948 100644 --- a/pkg/containerprofilemanager/v1/host_profile_content_test.go +++ b/pkg/containerprofilemanager/v1/host_profile_content_test.go @@ -8,6 +8,7 @@ import ( "github.com/armosec/armoapi-go/armotypes" containercollection "github.com/inspektor-gadget/inspektor-gadget/pkg/container-collection" + "github.com/inspektor-gadget/inspektor-gadget/pkg/types" "github.com/kubescape/node-agent/pkg/config" "github.com/kubescape/node-agent/pkg/dnsmanager" "github.com/kubescape/node-agent/pkg/hostidentity" @@ -30,7 +31,7 @@ import ( // TestContainerCallback_HostReachesStorage's (pkg/objectcache/ // containerprofilecache/host_shared_data_test.go): that test only exercises // the READ/overlay cache path (GetContainerProfile being called at all). This -// test drives real events (exec/open/syscall/capability) through this +// test drives real events (exec/open/syscall/capability/network) through this // package's WRITE path -- ContainerProfileManager, the component that // actually assembles and submits the CR -- for the host pseudo-container, and // asserts on the resulting CR's Spec content. @@ -131,6 +132,13 @@ func TestHostContainerProfile_ContentPopulated(t *testing.T) { }) cpm.ReportSyscalls(armotypes.HostContainerID, []string{"execve", "openat"}) cpm.ReportCapability(armotypes.HostContainerID, "CAP_SYS_ADMIN") + cpm.ReportNetworkEvent(armotypes.HostContainerID, &utils.StructEvent{ + DstEndpoint: types.L3Endpoint{Addr: "203.0.113.10"}, + DstPort: 443, + Proto: "tcp", + PktType: utils.OutgoingPktType, + HostNetwork: true, + }) // Force an immediate save (mirrors the termination/max-time forced-save // callers already in monitoring.go) rather than waiting on the real @@ -163,4 +171,10 @@ func TestHostContainerProfile_ContentPopulated(t *testing.T) { assert.NotEmpty(t, cr.Spec.Opens, "host CR must carry real open data, not an empty shell") assert.NotEmpty(t, cr.Spec.Syscalls, "host CR must carry real syscall data, not an empty shell") assert.NotEmpty(t, cr.Spec.Capabilities, "host CR must carry real capability data, not an empty shell") + require.Len(t, cr.Spec.Egress, 1, "host network observations must reach the stored profile") + assert.Equal(t, "203.0.113.10", cr.Spec.Egress[0].IPAddress) + require.Len(t, cr.Spec.Egress[0].Ports, 1) + require.NotNil(t, cr.Spec.Egress[0].Ports[0].Port) + assert.Equal(t, int32(443), *cr.Spec.Egress[0].Ports[0].Port) + assert.Equal(t, "tcp", string(cr.Spec.Egress[0].Ports[0].Protocol)) } diff --git a/pkg/objectcache/containerprofilecache/containerprofilecache.go b/pkg/objectcache/containerprofilecache/containerprofilecache.go index 410ce1aa7c..e085f093cc 100644 --- a/pkg/objectcache/containerprofilecache/containerprofilecache.go +++ b/pkg/objectcache/containerprofilecache/containerprofilecache.go @@ -71,6 +71,9 @@ type CachedContainerProfile struct { // reconciler to re-fetch on refresh. UserCPRef *namespacedName + // Retain host metadata for retry when an unbound host has no learned CP yet. + hostRetry *pendingContainer + // CPName is the storage name of the ContainerProfile. Populated at // addContainer time so the reconciler can re-fetch without re-querying // shared data (which may have been evicted from K8sObjectCache by then). @@ -129,6 +132,9 @@ type ContainerProfileCacheImpl struct { k8sObjectCache objectcache.K8sObjectCache metricsManager metricsmanager.MetricsManager + // Installed before Start; nil for sources without Kubernetes Nodes. + hostLabelSource func(context.Context) (map[string]string, error) + reconcileEvery time.Duration rpcBudget time.Duration @@ -198,6 +204,23 @@ func NewContainerProfileCache(cfg config.Config, storageClient storage.ProfileCl return c } +// SetHostLabelSource installs the Kubernetes Node label reader before Start or +// ContainerCallback. Errors preserve the current binding; an empty label map +// removes it. Authored host profiles live in the agent's namespace. +func (c *ContainerProfileCacheImpl) SetHostLabelSource(fn func(context.Context) (map[string]string, error)) { + c.hostLabelSource = fn +} + +func (c *ContainerProfileCacheImpl) hostProfileLabel(ctx context.Context) (string, error) { + var labels map[string]string + err := c.refreshRPC(ctx, func(ctx context.Context) error { + var err error + labels, err = c.hostLabelSource(ctx) + return err + }) + return labels[helpersv1.UserDefinedProfileMetadataKey], err +} + // refreshRPC calls fn with a context bounded by c.rpcBudget, enforcing a // per-call SLO so a slow API server cannot stall a full reconciler burst. func (c *ContainerProfileCacheImpl) refreshRPC(ctx context.Context, fn func(context.Context) error) error { @@ -400,6 +423,13 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( sharedData *objectcache.WatchedContainerData, cpName, workloadName string, ) bool { + if utils.IsHostContainer(container) && container.Runtime.ContainerName == "" { + // GetHostAsContainer has no runtime name. Authored sections and later + // refreshes use the synthetic host name without mutating collection data. + containerCopy := *container + containerCopy.Runtime.ContainerName = "host" + container = &containerCopy + } ns := container.K8s.Namespace // Fetch consolidated ContainerProfile. The storage server aggregates the @@ -435,6 +465,15 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( // drives the reconciler to retry the CP on every tick until it materialises. var userDefinedCP *v1beta1.ContainerProfile overlayName, hasOverlay := container.K8s.PodLabels[helpersv1.UserDefinedProfileMetadataKey] + if utils.IsHostContainer(container) && c.hostLabelSource != nil { + var err error + overlayName, err = c.hostProfileLabel(ctx) + if err != nil { + logger.L().Debug("host Node labels unavailable; deferring profile population", helpers.Error(err)) + return false + } + hasOverlay = overlayName != "" + } // resolvedOverlayName is the ContainerProfile name the label ultimately // resolves to; it is recorded in entry.UserCPRef so refreshOneEntry re-fetches // the SAME object every tick. It defaults to the bare label value — the @@ -450,6 +489,10 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( // pods). A transient error is NOT a fallback trigger — the CP is left nil // for this tick and the bare name stays the retry target. perContainerName := overlayName + "-" + container.Runtime.ContainerName + if utils.IsHostContainer(container) { + // A Node has one host profile; its label names that object directly. + perContainerName = overlayName + } var userCPErr error _ = c.refreshRPC(ctx, func(rctx context.Context) error { userDefinedCP, userCPErr = c.storageClient.GetContainerProfile(rctx, ns, perContainerName) @@ -458,7 +501,7 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( switch { case userCPErr == nil && userDefinedCP != nil: resolvedOverlayName = perContainerName - case apierrors.IsNotFound(userCPErr): + case apierrors.IsNotFound(userCPErr) && perContainerName != overlayName: // Fall back to the bare overlay name (single-container convention). userDefinedCP = nil var bareErr error @@ -586,6 +629,9 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( // WorkloadName is the synthesize-name source refreshOneEntry uses when it // rebuilds an entry whose consolidated CP is not yet in storage. entry.WorkloadName = workloadName + if utils.IsHostContainer(container) { + entry.hostRetry = &pendingContainer{container: container, sharedData: sharedData, cpName: cpName, workloadName: workloadName} + } // When the overlay label is set, ALWAYS record UserCPRef so the reconciler // keeps probing for the user-authored ContainerProfile on every tick — even diff --git a/pkg/objectcache/containerprofilecache/host_binding_test.go b/pkg/objectcache/containerprofilecache/host_binding_test.go new file mode 100644 index 0000000000..d5e8ec2b36 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/host_binding_test.go @@ -0,0 +1,197 @@ +package containerprofilecache + +import ( + "context" + "errors" + "testing" + + "github.com/armosec/armoapi-go/armotypes" + helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" + "github.com/kubescape/node-agent/pkg/hostidentity" + "github.com/kubescape/node-agent/pkg/objectcache" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/require" +) + +func hostBindingCache(t *testing.T, label *string, sourceErr *error) (*ContainerProfileCacheImpl, *countingProfileClient, func() bool) { + t.Helper() + client := &countingProfileClient{ + cp: &v1beta1.ContainerProfile{Name: "learned", Namespace: "kubescape", ResourceVersion: "1", Annotations: map[string]string{helpersv1.StatusMetadataKey: helpersv1.Completed, helpersv1.CompletionMetadataKey: helpersv1.Full}}, + userCP: &v1beta1.ContainerProfile{Name: "authored", Namespace: "kubescape", ResourceVersion: "1"}, + } + client.cp.Spec.Capabilities = []string{"NET_ADMIN"} + client.userCP.Spec.Capabilities = []string{"SYS_PTRACE"} + c, k8s := newTestCache(t, client) + c.SetHostLabelSource(func(ctx context.Context) (map[string]string, error) { + _, bounded := ctx.Deadline() + require.True(t, bounded, "Node reads must have a deadline") + return map[string]string{helpersv1.UserDefinedProfileMetadataKey: *label}, *sourceErr + }) + container := hostEventContainer() + // Match GetHostAsContainer: the runtime does not supply a host name. + container.Runtime.ContainerName = "" + container.K8s.Namespace = "kubescape" + data := hostidentity.BuildHostWatchedContainerData("node-1") + k8s.SetSharedContainerData(armotypes.HostContainerID, data) + populate := func() bool { + populated := c.tryPopulateEntry(context.Background(), armotypes.HostContainerID, container, data, "learned", "host-node-1") + require.Empty(t, container.Runtime.ContainerName, "cache must not mutate collection metadata") + return populated + } + return c, client, populate +} + +func hostBindingEntry(t *testing.T, c *ContainerProfileCacheImpl) *CachedContainerProfile { + t.Helper() + entry, ok := c.entries.Load(armotypes.HostContainerID) + require.True(t, ok) + return entry +} + +func TestHostNodeLabelBindingLifecycle(t *testing.T) { + label := "" + var sourceErr error + c, client, populate := hostBindingCache(t, &label, &sourceErr) + require.True(t, populate()) + require.Nil(t, hostBindingEntry(t, c).UserCPRef) + refresh := func() { c.refreshOneEntry(context.Background(), armotypes.HostContainerID, hostBindingEntry(t, c)) } + + label = "authored" + refresh() + entry := hostBindingEntry(t, c) + require.Equal(t, &namespacedName{Namespace: "kubescape", Name: "authored"}, entry.UserCPRef) + require.Equal(t, "learned", entry.CPName) + require.Equal(t, map[string]struct{}{"SYS_PTRACE": {}}, entry.Projected.Capabilities.Values) + require.Equal(t, &objectcache.ProfileState{Name: "authored", Status: helpersv1.Completed, Completion: helpersv1.Full}, entry.State) + + // Repointing must rebuild even when both profiles have the same RV. + label = "replacement" + client.userCP = &v1beta1.ContainerProfile{Name: label, Namespace: "kubescape", ResourceVersion: "1", Spec: v1beta1.ContainerProfileSpec{Capabilities: []string{"SYS_ADMIN"}}} + refresh() + require.Equal(t, label, hostBindingEntry(t, c).State.Name) + require.Equal(t, map[string]struct{}{"SYS_ADMIN": {}}, hostBindingEntry(t, c).Projected.Capabilities.Values) + + // A failed Node GET is not evidence that its binding was removed. + previous := hostBindingEntry(t, c) + label = "" + sourceErr = errors.New("Node API unavailable") + refresh() + require.Same(t, previous, hostBindingEntry(t, c)) + sourceErr = nil + refresh() + require.Nil(t, hostBindingEntry(t, c).UserCPRef) + require.Equal(t, "learned", hostBindingEntry(t, c).State.Name) + require.Equal(t, map[string]struct{}{"NET_ADMIN": {}}, hostBindingEntry(t, c).Projected.Capabilities.Values) +} + +func TestHostNodeLabelInitialFailureAndRetry(t *testing.T) { + label := "authored" + sourceErr := errors.New("Node API unavailable") + c, _, populate := hostBindingCache(t, &label, &sourceErr) + require.False(t, populate()) + require.False(t, c.entries.Has(armotypes.HostContainerID)) + sourceErr = nil + require.True(t, populate()) + require.Equal(t, label, hostBindingEntry(t, c).State.Name) +} + +func TestHostNodeLabelRemovalWithoutLearnedProfileRetries(t *testing.T) { + label := "authored" + var sourceErr error + c, client, populate := hostBindingCache(t, &label, &sourceErr) + client.cp = nil + require.True(t, populate()) + label = "" + c.refreshOneEntry(context.Background(), armotypes.HostContainerID, hostBindingEntry(t, c)) + require.False(t, c.entries.Has(armotypes.HostContainerID)) + require.True(t, c.pending.Has(armotypes.HostContainerID)) + label = "authored" + c.retryPendingEntries(context.Background()) + require.Equal(t, label, hostBindingEntry(t, c).State.Name) +} + +func TestHostNodeBindingStorageFailureRetainsPreviousProfile(t *testing.T) { + for _, target := range []string{"replacement", ""} { + t.Run("target="+target, func(t *testing.T) { + label := "authored" + var sourceErr error + c, client, populate := hostBindingCache(t, &label, &sourceErr) + require.True(t, populate()) + previous := hostBindingEntry(t, c) + failureName := "replacement" + if target == "" { + failureName = "learned" + } + scripted := &scriptedProfileClient{ + byName: map[string]*v1beta1.ContainerProfile{"learned": client.cp, "authored": client.userCP}, + errByName: map[string]error{failureName: errors.New("storage unavailable")}, + } + c.storageClient = scripted + label = target + c.refreshOneEntry(context.Background(), armotypes.HostContainerID, previous) + require.Same(t, previous, hostBindingEntry(t, c)) + require.Equal(t, "authored", previous.UserCPRef.Name) + require.Equal(t, map[string]struct{}{"SYS_PTRACE": {}}, previous.Projected.Capabilities.Values) + }) + } +} + +func TestHostNodeBindingUnresolvedProfileFallsBackAndRetries(t *testing.T) { + for _, invalid := range []bool{false, true} { + t.Run(map[bool]string{false: "missing", true: "learned-instead-of-authored"}[invalid], func(t *testing.T) { + label := "authored" + var sourceErr error + c, client, populate := hostBindingCache(t, &label, &sourceErr) + require.True(t, populate()) + label = "unresolved" + if invalid { + client.userCP = client.cp.DeepCopy() + client.userCP.Name = label + } + c.refreshOneEntry(context.Background(), armotypes.HostContainerID, hostBindingEntry(t, c)) + entry := hostBindingEntry(t, c) + require.Equal(t, label, entry.UserCPRef.Name) + require.Equal(t, "learned", entry.State.Name) + require.Equal(t, map[string]struct{}{"NET_ADMIN": {}}, entry.Projected.Capabilities.Values) + client.userCP = &v1beta1.ContainerProfile{Name: label, Namespace: "kubescape", ResourceVersion: "2", Spec: v1beta1.ContainerProfileSpec{Capabilities: []string{"SYS_ADMIN"}}} + c.refreshOneEntry(context.Background(), armotypes.HostContainerID, entry) + require.Equal(t, map[string]struct{}{"SYS_ADMIN": {}}, hostBindingEntry(t, c).Projected.Capabilities.Values) + }) + } +} + +func TestHostNodeGroupedProfileBindingWithEmptyRuntimeName(t *testing.T) { + for _, initiallyBound := range []bool{true, false} { + t.Run(map[bool]string{true: "initial-binding", false: "later-binding"}[initiallyBound], func(t *testing.T) { + label := "" + if initiallyBound { + label = "authored" + } + var sourceErr error + c, client, populate := hostBindingCache(t, &label, &sourceErr) + client.userCP.Spec = v1beta1.ContainerProfileSpec{ + Containers: []v1beta1.ContainerProfileContainer{ + {Name: "other", Capabilities: []string{"SYS_ADMIN"}}, + {Name: "host", Capabilities: []string{"SYS_PTRACE"}}, + }, + } + require.True(t, populate()) + require.Equal(t, "host", hostBindingEntry(t, c).ContainerName) + if !initiallyBound { + require.Equal(t, map[string]struct{}{"NET_ADMIN": {}}, hostBindingEntry(t, c).Projected.Capabilities.Values) + label = "authored" + c.refreshOneEntry(context.Background(), armotypes.HostContainerID, hostBindingEntry(t, c)) + } + entry := hostBindingEntry(t, c) + require.Equal(t, &namespacedName{Namespace: "kubescape", Name: "authored"}, entry.UserCPRef) + require.Equal(t, "authored", entry.State.Name) + require.Equal(t, map[string]struct{}{"SYS_PTRACE": {}}, entry.Projected.Capabilities.Values) + + // Refresh must retain the normalized name when reselecting the section. + client.userCP.ResourceVersion = "2" + client.userCP.Spec.Containers[1].Capabilities = []string{"NET_RAW"} + c.refreshOneEntry(context.Background(), armotypes.HostContainerID, entry) + require.Equal(t, map[string]struct{}{"NET_RAW": {}}, hostBindingEntry(t, c).Projected.Capabilities.Values) + }) + } +} diff --git a/pkg/objectcache/containerprofilecache/projection_apply.go b/pkg/objectcache/containerprofilecache/projection_apply.go index f0de872a52..de23c8e1cb 100644 --- a/pkg/objectcache/containerprofilecache/projection_apply.go +++ b/pkg/objectcache/containerprofilecache/projection_apply.go @@ -4,6 +4,7 @@ import ( "maps" "slices" "strings" + "sync/atomic" helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" "github.com/kubescape/node-agent/pkg/objectcache" @@ -12,12 +13,14 @@ import ( "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" ) +var projectionGeneration atomic.Uint64 + // Apply transforms a raw ContainerProfile into a ProjectedContainerProfile -// under the given spec. Pure function: no I/O, no mutation of inputs. +// under the given spec, assigning a process-local cache generation. +// It performs no I/O and does not mutate its inputs. // If spec is nil, a zero-spec is used — InUse=false on every field triggers // pass-through, retaining all raw data. -// callStackTree is built by the caller and passed in so Apply stays a pure -// data transform. +// callStackTree is built by the caller and passed in. func Apply(spec *objectcache.RuleProjectionSpec, cp *v1beta1.ContainerProfile, callStackTree *callstackcache.CallStackSearchTree) *objectcache.ProjectedContainerProfile { var s objectcache.RuleProjectionSpec if spec != nil { @@ -25,8 +28,9 @@ func Apply(spec *objectcache.RuleProjectionSpec, cp *v1beta1.ContainerProfile, c } pcp := &objectcache.ProjectedContainerProfile{ - SpecHash: s.Hash, - CallStackTree: callStackTree, + CacheGeneration: projectionGeneration.Add(1), + SpecHash: s.Hash, + CallStackTree: callStackTree, } if cp == nil { diff --git a/pkg/objectcache/containerprofilecache/projection_generation_test.go b/pkg/objectcache/containerprofilecache/projection_generation_test.go new file mode 100644 index 0000000000..6f8c39934c --- /dev/null +++ b/pkg/objectcache/containerprofilecache/projection_generation_test.go @@ -0,0 +1,27 @@ +package containerprofilecache + +import ( + "sync" + "testing" + + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/require" +) + +func TestApplyCacheGenerationUniqueAcrossConcurrentRebuilds(t *testing.T) { + const builds = 32 + generations := make(chan uint64, builds) + var wg sync.WaitGroup + profile := &v1beta1.ContainerProfile{} + for range builds { + wg.Go(func() { generations <- Apply(nil, profile, nil).CacheGeneration }) + } + wg.Wait() + close(generations) + seen := make(map[uint64]bool, builds) + for generation := range generations { + require.NotZero(t, generation) + require.False(t, seen[generation], "each rebuild needs a fresh CEL cache key") + seen[generation] = true + } +} diff --git a/pkg/objectcache/containerprofilecache/reconciler.go b/pkg/objectcache/containerprofilecache/reconciler.go index 09fe29da5d..83d155e808 100644 --- a/pkg/objectcache/containerprofilecache/reconciler.go +++ b/pkg/objectcache/containerprofilecache/reconciler.go @@ -21,6 +21,7 @@ import ( "errors" "time" + "github.com/armosec/armoapi-go/armotypes" "github.com/kubescape/go-logger" "github.com/kubescape/go-logger/helpers" helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" @@ -365,6 +366,31 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri return } + // Read Node labels on every refresh so adding, replacing, or removing the + // binding takes effect without restarting the agent. Work on a copy so a + // failed profile fetch cannot publish half of a binding transition. + bindingChanged := false + if id == armotypes.HostContainerID && c.hostLabelSource != nil { + label, err := c.hostProfileLabel(ctx) + if err != nil { + logger.L().Debug("host Node labels unavailable; keeping cached profile", helpers.Error(err)) + return + } + previousLabel := "" + if e.UserCPRef != nil { + previousLabel = e.UserCPRef.Name + } + if label != previousLabel { + bindingChanged = true + copy := *e + e = © + e.UserCPRef = nil + if label != "" { + e.UserCPRef = &namespacedName{Namespace: e.Namespace, Name: label} + } + } + } + ns := e.Namespace // Snapshot the projection spec before the fetch only to decide whether it is @@ -408,7 +434,7 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri // // Attached per call, never to the shared ctx: the authored-CP fetch below // derives from the same ctx and must never carry the learned CP's checksum. - validatorEligible := e.UserCPRef == nil && e.UserCPRV == "" && e.SpecHash == preFetchSpecHash && + validatorEligible := !bindingChanged && e.UserCPRef == nil && e.UserCPRV == "" && e.SpecHash == preFetchSpecHash && e.State != nil && e.State.Status == helpersv1.Completed && e.State.Completion == helpersv1.Full validatorOffered := false requestMode := conditionalFetchModeMissing @@ -515,6 +541,9 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri logger.L().Debug("refreshOneEntry: no CP available after refresh; evicting entry", helpers.String("containerID", id), helpers.String("cpName", e.CPName)) + if e.hostRetry != nil { + c.pending.Set(id, e.hostRetry) + } c.entries.Delete(id) c.metricsManager.ReportContainerProfileReconcilerEviction("profile_deleted") return @@ -538,7 +567,7 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri if spec := c.snapshotSpec(); spec != nil { postFetchSpecHash = spec.Hash } - if rvsMatchCP(cp, e.RV) && + if !bindingChanged && rvsMatchCP(cp, e.RV) && checksumOfCP(cp) == e.Checksum && rvsMatchCP(userDefinedCP, e.UserCPRV) && e.SpecHash == postFetchSpecHash { @@ -629,6 +658,7 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( WorkloadID: prev.WorkloadID, CPName: prev.CPName, WorkloadName: prev.WorkloadName, + hostRetry: prev.hostRetry, RV: rvOfCP(cp), UserCPRV: rvOfCP(userDefinedCP), Checksum: checksumOfCP(cp), diff --git a/pkg/objectcache/projection_types.go b/pkg/objectcache/projection_types.go index 8d452c7a36..c7cf93bbf1 100644 --- a/pkg/objectcache/projection_types.go +++ b/pkg/objectcache/projection_types.go @@ -67,6 +67,10 @@ type ProjectedContainerProfile struct { // constraint" (back-compat for pre-projection profiles). ExecsByPath map[string][][]string + // CacheGeneration uniquely identifies each projection build within this process. + // It invalidates CEL results even when authored profiles have no sync checksum. + CacheGeneration uint64 `json:"-"` + SpecHash string SyncChecksum string PolicyByRuleId map[string]v1beta1.RulePolicy diff --git a/pkg/processtree/process_tree_manager.go b/pkg/processtree/process_tree_manager.go index 0d5373d1bf..bd74eb520f 100644 --- a/pkg/processtree/process_tree_manager.go +++ b/pkg/processtree/process_tree_manager.go @@ -91,6 +91,7 @@ func (ptm *ProcessTreeManagerImpl) GetContainerProcessTree(containerID string, p }() if subtreeErr != nil { + ptm.containerProcessTreeCache.Remove(cacheKey) return armotypes.Process{}, &GetContainerSubtreeError{Err: subtreeErr} } diff --git a/pkg/processtree/process_tree_manager_test.go b/pkg/processtree/process_tree_manager_test.go index df04fe71a4..4f24dd53ae 100644 --- a/pkg/processtree/process_tree_manager_test.go +++ b/pkg/processtree/process_tree_manager_test.go @@ -144,3 +144,54 @@ func TestManager_GetContainerProcessTree_CarriesStartTime(t *testing.T) { require.NoError(t, err) require.Equal(t, "nginx", bypassed.Comm) } + +func TestManager_GetContainerProcessTree_FailedRefreshDropsCachedBranch(t *testing.T) { + cfg := config.Config{KubernetesMode: true, ExitCleanup: treeconfig.ExitCleanupConfig{ + MaxPendingExits: 100, CleanupInterval: time.Hour, CleanupDelay: 5 * time.Minute, + }} + containerTree := containerprocesstree.NewContainerProcessTree() + creator := processtreecreator.NewProcessTreeCreator(containerTree, cfg) + mgr := NewProcessTreeManager(creator, containerTree, cfg) + + self := uint32(os.Getpid()) + shim := uint32(os.Getppid()) + require.NotEqual(t, self, shim, "test must run under a distinct parent pid") + + const containerID = "failed-refresh-container" + containerTree.ContainerCallback(containercollection.PubSubEvent{ + Type: containercollection.EventTypeAddContainer, + Container: &containercollection.Container{ + Runtime: containercollection.RuntimeMetadata{ + BasicRuntimeMetadata: eventtypes.BasicRuntimeMetadata{ContainerID: containerID, ContainerPID: self}, + }, + }, + }) + require.NoError(t, mgr.ReportEvent(utils.ProcfsEventType, &events.ProcfsEvent{PID: shim, PPID: 1, Comm: "containerd-shim", ContainerID: containerID})) + require.NoError(t, mgr.ReportEvent(utils.ProcfsEventType, &events.ProcfsEvent{PID: self, PPID: shim, Comm: "init", ContainerID: containerID})) + + const pid uint32 = 1 << 30 + require.NoError(t, mgr.ReportEvent(utils.ForkEventType, &utils.StructEvent{ + ContainerID: containerID, Pid: pid, Ppid: self, Comm: "python3", Timestamp: time.Now().UnixNano(), + })) + require.NoError(t, mgr.ReportEvent(utils.ExecveEventType, &utils.StructEvent{ + ContainerID: containerID, Pid: pid, Ppid: self, Comm: "python3", + ExePath: "/usr/bin/python3", Args: []string{"python3", "worker.py", "--job", "1"}, Timestamp: time.Now().UnixNano(), + })) + branch, err := mgr.GetContainerProcessTree(containerID, pid, false) + require.NoError(t, err) + require.Equal(t, self, branch.PID) + + require.NoError(t, mgr.ReportEvent(utils.ExitEventType, &utils.StructEvent{ + ContainerID: containerID, Pid: pid, Ppid: self, Timestamp: time.Now().UnixNano(), + })) + require.NoError(t, mgr.ReportEvent(utils.ExecveEventType, &utils.StructEvent{ + ContainerID: containerID, Pid: pid, Ppid: pid + 7, Comm: "python3", + ExePath: "/usr/bin/python3", Args: []string{"python3", "worker.py", "--job", "2"}, Timestamp: time.Now().UnixNano(), + })) + _, err = mgr.GetContainerProcessTree(containerID, pid, false) + require.Error(t, err, "refresh must fail while the new parent is unknown") + + cached, err := mgr.GetContainerProcessTree(containerID, pid, true) + require.Error(t, err, "cached read must not serve the previous process's branch") + require.Zero(t, cached.PID) +} diff --git a/pkg/rulemanager/cel/libraries/cache/function_cache.go b/pkg/rulemanager/cel/libraries/cache/function_cache.go index 176bc706c5..d2bc0a8c79 100644 --- a/pkg/rulemanager/cel/libraries/cache/function_cache.go +++ b/pkg/rulemanager/cel/libraries/cache/function_cache.go @@ -2,6 +2,7 @@ package cache import ( "fmt" + "strconv" "strings" "time" @@ -79,9 +80,9 @@ func NewFunctionCache(config FunctionCacheConfig) *FunctionCache { type CelFunction func(...ref.Val) ref.Val -// HashForContainerProfile returns a function that extracts the SpecHash of the -// projected profile for the containerID in values[0]. Passing this to WithCache -// ensures cached results are invalidated whenever the projection spec changes. +// HashForContainerProfile returns a cache token for the projected profile for +// the containerID in values[0]. Each projection rebuild changes its generation, +// including authored profile updates that have no SyncChecksum annotation. func HashForContainerProfile(oc objectcache.ObjectCache) func([]ref.Val) string { return func(values []ref.Val) string { if len(values) == 0 || oc == nil { @@ -99,7 +100,11 @@ func HashForContainerProfile(oc objectcache.ObjectCache) func([]ref.Val) string if pcp == nil { return "" } - // Include SyncChecksum so the key changes when profile content is updated + if pcp.CacheGeneration != 0 { + return "generation:" + strconv.FormatUint(pcp.CacheGeneration, 10) + } + // For manually constructed projections without a generation, + // include SyncChecksum so the key changes when profile content is updated // under the same projection spec, preventing stale cached results after // the profile learns new paths/execs/etc. return pcp.SpecHash + "|" + pcp.SyncChecksum diff --git a/pkg/rulemanager/cel/libraries/cache/profile_generation_test.go b/pkg/rulemanager/cel/libraries/cache/profile_generation_test.go new file mode 100644 index 0000000000..969cdae4e5 --- /dev/null +++ b/pkg/rulemanager/cel/libraries/cache/profile_generation_test.go @@ -0,0 +1,84 @@ +package cache_test + +import ( + "testing" + + "github.com/google/cel-go/cel" + "github.com/kubescape/node-agent/pkg/config" + "github.com/kubescape/node-agent/pkg/objectcache" + "github.com/kubescape/node-agent/pkg/objectcache/containerprofilecache" + objectcachev1 "github.com/kubescape/node-agent/pkg/objectcache/v1" + "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/containerprofile" + "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/containerprofilenetwork" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type projectedProfileCache struct { + objectcache.ContainerProfileCacheMock + profile *objectcache.ProjectedContainerProfile +} + +func (c *projectedProfileCache) GetProjectedContainerProfile(string) *objectcache.ProjectedContainerProfile { + return c.profile +} + +type profileObjectCache struct { + objectcachev1.RuleObjectCacheMock + profiles *projectedProfileCache +} + +func (c *profileObjectCache) ContainerProfileCache() objectcache.ContainerProfileCache { + return c.profiles +} + +func TestCELHelpersInvalidateWithoutSyncChecksum(t *testing.T) { + for _, expr := range []string{ + `cp.was_path_opened("host", "/etc/passwd")`, + `cp.was_capability_used("host", "SYS_PTRACE")`, + `cp.was_address_in_egress("host", "192.0.2.1")`, + } { + t.Run(expr, func(t *testing.T) { + profiles := &projectedProfileCache{} + oc := &profileObjectCache{profiles: profiles} + env, err := cel.NewEnv(containerprofile.CP(oc, config.Config{}), containerprofilenetwork.CPNetwork(oc, config.Config{})) + require.NoError(t, err) + ast, issues := env.Compile(expr) + require.NoError(t, issues.Err()) + program, err := env.Program(ast) + require.NoError(t, err) + spec := &objectcache.RuleProjectionSpec{Hash: "same-rules"} + evaluate := func(want bool) { + t.Helper() + // Repeated evaluations also exercise the cached path. + for range 2 { + result, _, err := program.Eval(map[string]any{}) + require.NoError(t, err) + require.Equal(t, want, result.Value()) + } + } + authored := &v1beta1.ContainerProfile{ObjectMeta: metav1.ObjectMeta{Name: "authored-a"}} + profiles.profile = containerprofilecache.Apply(spec, authored, nil) + evaluate(false) + + // Same identity and no checksum: a content update must invalidate false. + authored.Spec.Capabilities = []string{"SYS_PTRACE"} + authored.Spec.Opens = []v1beta1.OpenCalls{{Path: "/etc/passwd"}} + authored.Spec.Egress = []v1beta1.NetworkNeighbor{{IPAddress: "192.0.2.1"}} + profiles.profile = containerprofilecache.Apply(spec, authored, nil) + evaluate(true) + + // Rebinding to another authored profile must invalidate true. + rebound := &v1beta1.ContainerProfile{ObjectMeta: metav1.ObjectMeta{Name: "authored-b"}} + profiles.profile = containerprofilecache.Apply(spec, rebound, nil) + evaluate(false) + + // Removing then restoring the binding must not retain the absent result. + profiles.profile = nil + evaluate(false) + profiles.profile = containerprofilecache.Apply(spec, authored, nil) + evaluate(true) + }) + } +} diff --git a/pkg/rulemanager/ruleadapters/creator.go b/pkg/rulemanager/ruleadapters/creator.go index 5e58d8ad63..749602ef1d 100644 --- a/pkg/rulemanager/ruleadapters/creator.go +++ b/pkg/rulemanager/ruleadapters/creator.go @@ -97,16 +97,36 @@ func (r *RuleFailureCreator) CreateRuleFailure(rule typesv1.Rule, enrichedEvent r.setContextSpecificFields(ruleFailure, enrichedEvent) r.enrichRuleFailure(ruleFailure) - if enrichedEvent.ProcessTree.PID != 0 { - ruleFailure.SetRuntimeProcessDetails(armotypes.ProcessTree{ - ProcessTree: enrichedEvent.ProcessTree, - ContainerID: enrichedEvent.ContainerID, - }) + // Validate against the source event: adapters may omit process fields + // from alert metadata (for example, HTTP alerts omit Comm). + if event, ok := enrichedEvent.Event.(utils.EnrichEvent); ok { + eventProcess := armotypes.Process{PID: event.GetPID(), Comm: event.GetComm()} + // Syscall events can carry a container name instead of a process comm. + isSyscallEvent := event.GetEventType() == utils.SyscallEventType + if tree, ok := eventProcessTree(enrichedEvent.ProcessTree, eventProcess, isSyscallEvent); ok { + ruleFailure.SetRuntimeProcessDetails(armotypes.ProcessTree{ + ProcessTree: tree, + ContainerID: enrichedEvent.ContainerID, + }) + } } return ruleFailure } +// eventProcessTree accepts enrichment only when it contains the event process. +// A mismatched comm can indicate PID reuse or a stale tree after exec. +func eventProcessTree(branch armotypes.Process, eventProcess armotypes.Process, isSyscallEvent bool) (armotypes.Process, bool) { + if branch.PID == 0 || eventProcess.PID == 0 { + return armotypes.Process{}, false + } + process := utils.GetProcessFromProcessTree(&branch, eventProcess.PID) + if process == nil || (!isSyscallEvent && (eventProcess.Comm == "" || process.Comm != eventProcess.Comm)) { + return armotypes.Process{}, false + } + return branch, true +} + func (r *RuleFailureCreator) enrichRuleFailure(ruleFailure *types.GenericRuleFailure) { if r.enricher != nil && !reflect.ValueOf(r.enricher).IsNil() { if err := r.enricher.EnrichRuleFailure(ruleFailure); err != nil { diff --git a/pkg/rulemanager/ruleadapters/creator_attribution_test.go b/pkg/rulemanager/ruleadapters/creator_attribution_test.go new file mode 100644 index 0000000000..80d8ef791f --- /dev/null +++ b/pkg/rulemanager/ruleadapters/creator_attribution_test.go @@ -0,0 +1,141 @@ +package ruleadapters + +import ( + "net/http" + "testing" + + "github.com/armosec/armoapi-go/armotypes" + "github.com/goradd/maps" + "github.com/kubescape/node-agent/pkg/dnsmanager" + "github.com/kubescape/node-agent/pkg/ebpf/events" + typesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/kubescape/node-agent/pkg/utils" + "github.com/stretchr/testify/require" +) + +func TestCreateRuleFailureProcessAttribution(t *testing.T) { + for _, eventType := range []utils.EventType{utils.ExecveEventType, utils.NetworkEventType, utils.SyscallEventType} { + t.Run(string(eventType), func(t *testing.T) { + for _, tc := range []struct { + name string + tree armotypes.Process + wantTree bool + }{ + {name: "missing tree"}, + {name: "unrelated PID", tree: armotypes.Process{PID: 999, Comm: "curl", Path: "/unrelated/curl"}}, + {name: "stale comm", tree: armotypes.Process{PID: 42, Comm: "old-process", Path: "/old/process"}, wantTree: eventType == utils.SyscallEventType}, + {name: "matching root", tree: armotypes.Process{PID: 42, Comm: "curl", Path: "/enriched/curl"}, wantTree: true}, + {name: "matching descendant", tree: armotypes.Process{PID: 1, Comm: "init", ChildrenMap: map[armotypes.CommPID]*armotypes.Process{{Comm: "curl", PID: 42}: {PID: 42, Comm: "curl", Path: "/enriched/curl"}}}, wantTree: true}, + } { + t.Run(tc.name, func(t *testing.T) { + creator := NewRuleFailureCreator(nil, dnsmanager.CreateDNSManagerMock(), NewEventRuleAdapterFactory(), 0, "test", new(maps.SafeMap[string, uint32])) + event := &utils.StructEvent{EventType: eventType, ContainerID: armotypes.HostContainerID, Pid: 42, Comm: "curl", ExePath: "/event/curl", Args: []string{"curl", "example.com"}, Uid: 1000, Gid: 1000} + enriched := &events.EnrichedEvent{Event: event, ContainerID: armotypes.HostContainerID, ProcessTree: tc.tree} + failure := creator.CreateRuleFailure(typesv1.Rule{ProfileDependency: armotypes.NotRequired}, enriched, nil, "test", "id", "", nil) + require.NotNil(t, failure) + require.Equal(t, uint32(42), failure.GetBaseRuntimeAlert().InfectedPID, "infected PID must remain the event PID, including when enrichment starts at an ancestor") + require.Equal(t, "curl", failure.GetBaseRuntimeAlert().Identifiers.Process.Name) + details := failure.GetRuntimeProcessDetails() + require.Equal(t, armotypes.HostContainerID, details.ContainerID) + if tc.wantTree { + require.Equal(t, tc.tree, details.ProcessTree) + } else { + require.Equal(t, uint32(42), details.ProcessTree.PID) + require.Equal(t, "curl", details.ProcessTree.Comm) + require.Empty(t, details.ProcessTree.ChildrenMap) + if eventType != utils.SyscallEventType { + require.Equal(t, "/event/curl", details.ProcessTree.Path) + require.NotNil(t, details.ProcessTree.Uid) + require.Equal(t, uint32(1000), *details.ProcessTree.Uid) + require.NotNil(t, details.ProcessTree.Gid) + require.Equal(t, uint32(1000), *details.ProcessTree.Gid) + } + } + }) + } + }) + } +} + +func TestEventProcessTreeRequiresPID(t *testing.T) { + for _, tc := range []struct { + name string + branch armotypes.Process + event armotypes.Process + isSyscall bool + want bool + }{ + {name: "zero event PID", branch: armotypes.Process{PID: 1}}, + {name: "zero tree PID", event: armotypes.Process{PID: 42}}, + {name: "empty comm still requires matching PID", branch: armotypes.Process{PID: 1}, event: armotypes.Process{PID: 42}}, + {name: "empty comm rejects matching PID", branch: armotypes.Process{PID: 42, Comm: "curl"}, event: armotypes.Process{PID: 42}}, + {name: "syscall empty comm allows matching PID", branch: armotypes.Process{PID: 42, Comm: "curl"}, event: armotypes.Process{PID: 42}, isSyscall: true, want: true}, + {name: "syscall still requires matching PID", branch: armotypes.Process{PID: 1}, event: armotypes.Process{PID: 42}, isSyscall: true}, + } { + t.Run(tc.name, func(t *testing.T) { + tree, ok := eventProcessTree(tc.branch, tc.event, tc.isSyscall) + require.Equal(t, tc.want, ok) + if ok { + require.Equal(t, tc.branch, tree) + } + }) + } +} + +func TestCreateRuleFailureEmptyCommAttribution(t *testing.T) { + for _, eventType := range []utils.EventType{utils.ExecveEventType, utils.NetworkEventType, utils.SyscallEventType} { + t.Run(string(eventType), func(t *testing.T) { + creator := NewRuleFailureCreator(nil, dnsmanager.CreateDNSManagerMock(), NewEventRuleAdapterFactory(), 0, "test", new(maps.SafeMap[string, uint32])) + event := &utils.StructEvent{EventType: eventType, ContainerID: armotypes.HostContainerID, Pid: 42, ExePath: "/event/curl"} + tree := armotypes.Process{PID: 42, Comm: "unverified", Path: "/unverified/process"} + failure := creator.CreateRuleFailure(typesv1.Rule{ProfileDependency: armotypes.NotRequired}, &events.EnrichedEvent{Event: event, ContainerID: armotypes.HostContainerID, ProcessTree: tree}, nil, "test", "id", "", nil) + require.NotNil(t, failure) + require.Equal(t, uint32(42), failure.GetBaseRuntimeAlert().InfectedPID) + if eventType == utils.SyscallEventType { + require.Equal(t, tree, failure.GetRuntimeProcessDetails().ProcessTree) + } else { + require.Empty(t, failure.GetRuntimeProcessDetails().ProcessTree.Comm) + require.Equal(t, "/event/curl", failure.GetRuntimeProcessDetails().ProcessTree.Path) + } + }) + } +} + +func TestCreateRuleFailureHTTPProcessAttribution(t *testing.T) { + for _, tc := range []struct { + name string + pid uint32 + comm string + tree armotypes.Process + wantTree bool + }{ + {name: "matching root", pid: 42, comm: "curl", tree: armotypes.Process{PID: 42, Comm: "curl", Path: "/enriched/curl"}, wantTree: true}, + {name: "matching descendant", pid: 42, comm: "curl", tree: armotypes.Process{PID: 1, Comm: "init", ChildrenMap: map[armotypes.CommPID]*armotypes.Process{{Comm: "curl", PID: 42}: {PID: 42, Comm: "curl", Path: "/enriched/curl"}}}, wantTree: true}, + {name: "mismatched command", pid: 42, comm: "curl", tree: armotypes.Process{PID: 42, Comm: "stale", Path: "/stale/process"}}, + {name: "empty event command", pid: 42, tree: armotypes.Process{PID: 42, Comm: "curl", Path: "/enriched/curl"}}, + {name: "empty tree command", pid: 42, comm: "curl", tree: armotypes.Process{PID: 42, Path: "/enriched/curl"}}, + {name: "mismatched PID", pid: 42, comm: "curl", tree: armotypes.Process{PID: 99, Comm: "curl", Path: "/unrelated/curl"}}, + {name: "zero event PID", comm: "curl", tree: armotypes.Process{PID: 42, Comm: "curl"}}, + {name: "zero tree PID", pid: 42, comm: "curl"}, + } { + t.Run(tc.name, func(t *testing.T) { + request, err := http.NewRequest(http.MethodGet, "http://example.com/test", nil) + require.NoError(t, err) + event := &utils.StructEvent{EventType: utils.HTTPEventType, ContainerID: armotypes.HostContainerID, Pid: tc.pid, Comm: tc.comm, Request: request} + creator := NewRuleFailureCreator(nil, dnsmanager.CreateDNSManagerMock(), NewEventRuleAdapterFactory(), 0, "test", new(maps.SafeMap[string, uint32])) + failure := creator.CreateRuleFailure(typesv1.Rule{ProfileDependency: armotypes.NotRequired}, &events.EnrichedEvent{Event: event, ContainerID: armotypes.HostContainerID, ProcessTree: tc.tree}, nil, "test", "id", "", nil) + require.NotNil(t, failure) + require.Equal(t, tc.pid, failure.GetBaseRuntimeAlert().InfectedPID) + details := failure.GetRuntimeProcessDetails() + require.Equal(t, armotypes.HostContainerID, details.ContainerID) + if tc.wantTree { + require.Equal(t, tc.tree, details.ProcessTree) + } else { + require.Equal(t, tc.pid, details.ProcessTree.PID) + require.Empty(t, details.ProcessTree.Comm) + require.Empty(t, details.ProcessTree.Path) + require.Empty(t, details.ProcessTree.ChildrenMap) + } + }) + } +} diff --git a/pkg/rulemanager/ruleadapters/creator_network_test.go b/pkg/rulemanager/ruleadapters/creator_network_test.go index 13169b0fc3..9008871e09 100644 --- a/pkg/rulemanager/ruleadapters/creator_network_test.go +++ b/pkg/rulemanager/ruleadapters/creator_network_test.go @@ -20,13 +20,13 @@ func TestCreateNetworkRuleFailurePathPrecedence(t *testing.T) { want string }{ {"missing tree uses socket path", "/socket/curl", armotypes.Process{}, "/socket/curl"}, - {"populated tree takes precedence", "/socket/curl", armotypes.Process{PID: 42, Path: "/tree/curl"}, "/tree/curl"}, - {"tree without path is retained", "/socket/curl", armotypes.Process{PID: 42}, ""}, + {"populated tree takes precedence", "/socket/curl", armotypes.Process{PID: 42, Comm: "curl", Path: "/tree/curl"}, "/tree/curl"}, + {"tree without path is retained", "/socket/curl", armotypes.Process{PID: 42, Comm: "curl"}, ""}, {"unattributed event and missing tree", "", armotypes.Process{}, ""}, } { t.Run(tc.name, func(t *testing.T) { creator := NewRuleFailureCreator(nil, dnsmanager.CreateDNSManagerMock(), NewEventRuleAdapterFactory(), 0, "test", new(maps.SafeMap[string, uint32])) - event := &utils.StructEvent{EventType: utils.NetworkEventType, ExePath: tc.eventPath, Pid: 42} + event := &utils.StructEvent{EventType: utils.NetworkEventType, ExePath: tc.eventPath, Pid: 42, Comm: "curl"} failure := creator.CreateRuleFailure(typesv1.Rule{ProfileDependency: armotypes.NotRequired}, &events.EnrichedEvent{Event: event, ProcessTree: tc.tree}, nil, "test", "id", "", nil) require.NotNil(t, failure) require.Equal(t, tc.want, failure.GetRuntimeProcessDetails().ProcessTree.Path) diff --git a/tests/chart/templates/node-agent/default-rules.yaml b/tests/chart/templates/node-agent/default-rules.yaml index 512b4d9ec8..e12409f02d 100644 --- a/tests/chart/templates/node-agent/default-rules.yaml +++ b/tests/chart/templates/node-agent/default-rules.yaml @@ -30,6 +30,7 @@ spec: tags: - "context:kubernetes" - "context:container" + - "context:host" - "anomaly" - "process" - "exec" @@ -85,6 +86,7 @@ spec: tags: - "context:kubernetes" - "context:container" + - "context:host" - "anomaly" - "file" - "open" @@ -110,6 +112,7 @@ spec: tags: - "context:kubernetes" - "context:container" + - "context:host" - "anomaly" - "syscall" - "applicationprofile" @@ -134,6 +137,7 @@ spec: tags: - "context:kubernetes" - "context:container" + - "context:host" - "anomaly" - "capabilities" - "applicationprofile" @@ -350,6 +354,7 @@ spec: tags: - "context:kubernetes" - "context:container" + - "context:host" - "anomaly" - "process" - "exec"