diff --git a/cmd/main.go b/cmd/main.go index f8467a530..aca029b4d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -178,7 +178,7 @@ func main() { if cfg.EnablePrometheusExporter { prometheusExporter = metricprometheus.NewPrometheusMetric() } else { - prometheusExporter = metricsmanager.NewMetricsMock() + prometheusExporter = metricsmanager.NewMetricsNoop() } // Create watchers diff --git a/pkg/containerwatcher/v2/tracers/httpparse.go b/pkg/containerwatcher/v2/tracers/httpparse.go index d1c011cb4..345483aca 100644 --- a/pkg/containerwatcher/v2/tracers/httpparse.go +++ b/pkg/containerwatcher/v2/tracers/httpparse.go @@ -15,20 +15,6 @@ import ( "github.com/kubescape/storage/pkg/apis/softwarecomposition/consts" ) -var writeSyscalls = map[string]bool{ - "write": true, - "writev": true, - "sendto": true, - "sendmsg": true, -} - -var readSyscalls = map[string]bool{ - "read": true, - "readv": true, - "recvfrom": true, - "recvmsg": true, -} - var ConsistentHeaders = []string{ "Accept-Encoding", "Accept-Language", @@ -69,11 +55,12 @@ func ExtractConsistentHeaders(headers http.Header) map[string][]string { } func GetPacketDirection(syscall string) (consts.NetworkDirection, error) { - if readSyscalls[syscall] { + switch syscall { + case "read", "readv", "recvfrom", "recvmsg": return consts.Inbound, nil - } else if writeSyscalls[syscall] { + case "write", "writev", "sendto", "sendmsg": return consts.Outbound, nil - } else { + default: return "", fmt.Errorf("unknown syscall %s", syscall) } } diff --git a/pkg/metricsmanager/metrics_manager_interface.go b/pkg/metricsmanager/metrics_manager_interface.go index cce38824d..d4dcf49eb 100644 --- a/pkg/metricsmanager/metrics_manager_interface.go +++ b/pkg/metricsmanager/metrics_manager_interface.go @@ -13,6 +13,7 @@ type MetricsManager interface { ReportEvent(eventType utils.EventType) ReportFailedEvent() ReportRuleProcessed(ruleID string) + ReportRulePrefiltered(ruleName string) ReportRuleAlert(ruleID string) ReportRuleEvaluationTime(ruleID string, eventType utils.EventType, duration time.Duration) //ReportEbpfStats(stats *top.Event[toptypes.Stats]) diff --git a/pkg/metricsmanager/metrics_manager_mock.go b/pkg/metricsmanager/metrics_manager_mock.go index 1d8f24e66..dbb677155 100644 --- a/pkg/metricsmanager/metrics_manager_mock.go +++ b/pkg/metricsmanager/metrics_manager_mock.go @@ -60,6 +60,8 @@ func (m *MetricsMock) ReportRuleEvaluationTime(ruleID string, eventType utils.Ev //func (m *MetricsMock) ReportEbpfStats(stats *top.Event[toptypes.Stats]) { //} +func (m *MetricsMock) ReportRulePrefiltered(ruleName string) {} + func (m *MetricsMock) ReportContainerStart() {} func (m *MetricsMock) ReportContainerStop() {} diff --git a/pkg/metricsmanager/metrics_manager_noop.go b/pkg/metricsmanager/metrics_manager_noop.go new file mode 100644 index 000000000..a73d82cf2 --- /dev/null +++ b/pkg/metricsmanager/metrics_manager_noop.go @@ -0,0 +1,23 @@ +package metricsmanager + +import ( + "time" + + "github.com/kubescape/node-agent/pkg/utils" +) + +var _ MetricsManager = (*MetricsNoop)(nil) + +type MetricsNoop struct{} + +func NewMetricsNoop() *MetricsNoop { return &MetricsNoop{} } +func (m *MetricsNoop) Start() {} +func (m *MetricsNoop) Destroy() {} +func (m *MetricsNoop) ReportEvent(_ utils.EventType) {} +func (m *MetricsNoop) ReportFailedEvent() {} +func (m *MetricsNoop) ReportRuleProcessed(_ string) {} +func (m *MetricsNoop) ReportRulePrefiltered(_ string) {} +func (m *MetricsNoop) ReportRuleAlert(_ string) {} +func (m *MetricsNoop) ReportRuleEvaluationTime(_ string, _ utils.EventType, _ time.Duration) {} +func (m *MetricsNoop) ReportContainerStart() {} +func (m *MetricsNoop) ReportContainerStop() {} diff --git a/pkg/metricsmanager/prometheus/prometheus.go b/pkg/metricsmanager/prometheus/prometheus.go index 77f2eb22f..8cb39a1ab 100644 --- a/pkg/metricsmanager/prometheus/prometheus.go +++ b/pkg/metricsmanager/prometheus/prometheus.go @@ -41,8 +41,9 @@ type PrometheusMetric struct { ebpfKmodCounter prometheus.Counter ebpfUnshareCounter prometheus.Counter ebpfBpfCounter prometheus.Counter - ruleCounter *prometheus.CounterVec - alertCounter *prometheus.CounterVec + ruleCounter *prometheus.CounterVec + rulePrefilteredCounter *prometheus.CounterVec + alertCounter *prometheus.CounterVec ruleEvaluationTime *prometheus.HistogramVec // Program ID metrics @@ -60,8 +61,9 @@ type PrometheusMetric struct { containerStopCounter prometheus.Counter // Cache to avoid allocating Labels maps on every call - ruleCounterCache map[string]prometheus.Counter - alertCounterCache map[string]prometheus.Counter + ruleCounterCache map[string]prometheus.Counter + rulePrefilteredCounterCache map[string]prometheus.Counter + alertCounterCache map[string]prometheus.Counter counterCacheMutex sync.RWMutex } @@ -139,6 +141,10 @@ func NewPrometheusMetric() *PrometheusMetric { Name: "node_agent_rule_counter", Help: "The total number of rules processed by the engine", }, []string{prometheusRuleIdLabel}), + rulePrefilteredCounter: promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "node_agent_rule_prefiltered_total", + Help: "Total number of rule evaluations skipped by pre-filter", + }, []string{prometheusRuleIdLabel}), alertCounter: promauto.NewCounterVec(prometheus.CounterOpts{ Name: "node_agent_alert_counter", Help: "The total number of alerts sent by the engine", @@ -201,8 +207,9 @@ func NewPrometheusMetric() *PrometheusMetric { }), // Initialize counter caches - ruleCounterCache: make(map[string]prometheus.Counter), - alertCounterCache: make(map[string]prometheus.Counter), + ruleCounterCache: make(map[string]prometheus.Counter), + rulePrefilteredCounterCache: make(map[string]prometheus.Counter), + alertCounterCache: make(map[string]prometheus.Counter), } } @@ -225,6 +232,7 @@ func (p *PrometheusMetric) Destroy() { prometheus.Unregister(p.ebpfRandomXCounter) prometheus.Unregister(p.ebpfFailedCounter) prometheus.Unregister(p.ruleCounter) + prometheus.Unregister(p.rulePrefilteredCounter) prometheus.Unregister(p.alertCounter) prometheus.Unregister(p.ruleEvaluationTime) prometheus.Unregister(p.ebpfSymlinkCounter) @@ -342,6 +350,31 @@ func (p *PrometheusMetric) ReportRuleProcessed(ruleID string) { p.getCachedRuleCounter(ruleID).Inc() } +func (p *PrometheusMetric) getCachedRulePrefilteredCounter(ruleName string) prometheus.Counter { + p.counterCacheMutex.RLock() + counter, exists := p.rulePrefilteredCounterCache[ruleName] + p.counterCacheMutex.RUnlock() + + if exists { + return counter + } + + p.counterCacheMutex.Lock() + defer p.counterCacheMutex.Unlock() + + if counter, exists := p.rulePrefilteredCounterCache[ruleName]; exists { + return counter + } + + counter = p.rulePrefilteredCounter.With(prometheus.Labels{prometheusRuleIdLabel: ruleName}) + p.rulePrefilteredCounterCache[ruleName] = counter + return counter +} + +func (p *PrometheusMetric) ReportRulePrefiltered(ruleName string) { + p.getCachedRulePrefilteredCounter(ruleName).Inc() +} + func (p *PrometheusMetric) ReportRuleAlert(ruleID string) { p.getCachedAlertCounter(ruleID).Inc() } diff --git a/pkg/rulebindingmanager/cache/cache.go b/pkg/rulebindingmanager/cache/cache.go index 01fa6dcf2..af67961e6 100644 --- a/pkg/rulebindingmanager/cache/cache.go +++ b/pkg/rulebindingmanager/cache/cache.go @@ -15,6 +15,7 @@ import ( "github.com/kubescape/node-agent/pkg/k8sclient" "github.com/kubescape/node-agent/pkg/rulebindingmanager" typesv1 "github.com/kubescape/node-agent/pkg/rulebindingmanager/types/v1" + "github.com/kubescape/node-agent/pkg/rulemanager/prefilter" "github.com/kubescape/node-agent/pkg/rulemanager/rulecreator" rulemanagertypesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" "github.com/kubescape/node-agent/pkg/utils" @@ -400,14 +401,19 @@ func (c *RBCache) createRules(rulesForPod []typesv1.RuntimeAlertRuleBindingRule) func (c *RBCache) createRule(r *typesv1.RuntimeAlertRuleBindingRule) []rulemanagertypesv1.Rule { if r.RuleID != "" { rule := c.ruleCreator.CreateRuleByID(r.RuleID) + rule.Prefilter = prefilter.ParseWithDefaults(rule.State, r.Parameters) return []rulemanagertypesv1.Rule{rule} } if r.RuleName != "" { rule := c.ruleCreator.CreateRuleByName(r.RuleName) + rule.Prefilter = prefilter.ParseWithDefaults(rule.State, r.Parameters) return []rulemanagertypesv1.Rule{rule} } if len(r.RuleTags) > 0 { rules := c.ruleCreator.CreateRulesByTags(r.RuleTags) + for i := range rules { + rules[i].Prefilter = prefilter.ParseWithDefaults(rules[i].State, r.Parameters) + } return rules } diff --git a/pkg/rulebindingmanager/cache/cache_test.go b/pkg/rulebindingmanager/cache/cache_test.go index 3630a5952..424949d6d 100644 --- a/pkg/rulebindingmanager/cache/cache_test.go +++ b/pkg/rulebindingmanager/cache/cache_test.go @@ -14,6 +14,7 @@ import ( typesv1 "github.com/kubescape/node-agent/pkg/rulebindingmanager/types/v1" rulemanagertypesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -1040,3 +1041,46 @@ func TestDiff(t *testing.T) { }) } } + +func TestCreateRulePrefilter(t *testing.T) { + tests := []struct { + name string + binding *typesv1.RuntimeAlertRuleBindingRule + wantNil bool + wantIgnore []string + wantIncl []string + }{ + { + name: "parameters propagate to prefilter", + binding: &typesv1.RuntimeAlertRuleBindingRule{ + RuleID: "R0002", + Parameters: map[string]interface{}{ + "ignorePrefixes": []interface{}{"/tmp", "/var/log"}, + "includePrefixes": []interface{}{"/etc"}, + }, + }, + wantIgnore: []string{"/tmp/", "/var/log/"}, + wantIncl: []string{"/etc/"}, + }, + { + name: "nil parameters produce nil prefilter", + binding: &typesv1.RuntimeAlertRuleBindingRule{RuleID: "R0002"}, + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := NewCacheMock("") + rules := c.createRule(tt.binding) + require.Len(t, rules, 1) + if tt.wantNil { + assert.Nil(t, rules[0].Prefilter) + } else { + require.NotNil(t, rules[0].Prefilter) + assert.Equal(t, tt.wantIgnore, rules[0].Prefilter.IgnorePrefixes) + assert.Equal(t, tt.wantIncl, rules[0].Prefilter.IncludePrefixes) + } + }) + } +} diff --git a/pkg/rulebindingmanager/testdata/ruleparamsfiles/rule-ssh-params.yaml b/pkg/rulebindingmanager/testdata/ruleparamsfiles/rule-ssh-params.yaml index 606d432b2..094fddad0 100644 --- a/pkg/rulebindingmanager/testdata/ruleparamsfiles/rule-ssh-params.yaml +++ b/pkg/rulebindingmanager/testdata/ruleparamsfiles/rule-ssh-params.yaml @@ -15,4 +15,4 @@ spec: rules: - ruleName: "Malicious SSH Connection" parameters: - allowedPorts: [22, 2222] + ports: [22, 2222] diff --git a/pkg/rulemanager/extract_event_fields_test.go b/pkg/rulemanager/extract_event_fields_test.go new file mode 100644 index 000000000..ed988b0ff --- /dev/null +++ b/pkg/rulemanager/extract_event_fields_test.go @@ -0,0 +1,67 @@ +package rulemanager + +import ( + "net/http" + "testing" + + "github.com/kubescape/node-agent/pkg/rulemanager/prefilter" + "github.com/kubescape/node-agent/pkg/utils" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/consts" + "github.com/stretchr/testify/assert" +) + +func TestExtractEventFields(t *testing.T) { + tests := []struct { + name string + event *utils.StructEvent + expect prefilter.EventFields + }{ + { + name: "open event extracts path", + event: &utils.StructEvent{EventType: utils.OpenEventType, Path: "/etc/passwd"}, + expect: prefilter.EventFields{Path: "/etc/passwd", Extracted: true}, + }, + { + name: "exec event extracts exe path", + event: &utils.StructEvent{EventType: utils.ExecveEventType, ExePath: "/usr/bin/curl"}, + expect: prefilter.EventFields{Path: "/usr/bin/curl", Extracted: true}, + }, + { + name: "HTTP event extracts direction, method, port", + event: &utils.StructEvent{EventType: utils.HTTPEventType, Direction: consts.Inbound, DstPort: 8080, Request: &http.Request{Method: "POST"}}, + expect: prefilter.EventFields{Dir: prefilter.DirInbound, MethodBit: prefilter.MethodPOST, DstPort: 8080, Extracted: true}, + }, + { + name: "HTTP nil request leaves method zero", + event: &utils.StructEvent{EventType: utils.HTTPEventType, Direction: consts.Outbound}, + expect: prefilter.EventFields{Dir: prefilter.DirOutbound, Extracted: true}, + }, + { + name: "HTTP unknown direction maps to DirNone", + event: &utils.StructEvent{EventType: utils.HTTPEventType, Direction: "unknown", Request: &http.Request{Method: "POST"}}, + expect: prefilter.EventFields{MethodBit: prefilter.MethodPOST, Extracted: true}, + }, + { + name: "network event extracts port and sets PortEligible", + event: &utils.StructEvent{EventType: utils.NetworkEventType, DstPort: 443}, + expect: prefilter.EventFields{DstPort: 443, PortEligible: true, Extracted: true}, + }, + { + name: "SSH event extracts port and sets PortEligible", + event: &utils.StructEvent{EventType: utils.SSHEventType, DstPort: 22}, + expect: prefilter.EventFields{DstPort: 22, PortEligible: true, Extracted: true}, + }, + { + name: "unhandled event type returns empty fields", + event: &utils.StructEvent{EventType: utils.DnsEventType}, + expect: prefilter.EventFields{Extracted: true}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractEventFields(tt.event) + assert.Equal(t, tt.expect, got) + }) + } +} diff --git a/pkg/rulemanager/prefilter/prefilter.go b/pkg/rulemanager/prefilter/prefilter.go new file mode 100644 index 000000000..48172d1f7 --- /dev/null +++ b/pkg/rulemanager/prefilter/prefilter.go @@ -0,0 +1,253 @@ +package prefilter + +import ( + "slices" + "strings" +) + +// Direction represents an HTTP traffic direction as a compact integer. +type Direction uint8 + +const ( + DirNone Direction = 0 + DirInbound Direction = 1 + DirOutbound Direction = 2 +) + +func parseDirection(s string) Direction { + switch s { + case "inbound": + return DirInbound + case "outbound": + return DirOutbound + default: + return DirNone + } +} + +// MethodMask is a bitmask of HTTP methods for O(1) membership testing. +type MethodMask uint16 + +const ( + MethodGET MethodMask = 1 << iota + MethodHEAD // 2 + MethodPOST // 4 + MethodPUT // 8 + MethodPATCH // 16 + MethodDELETE // 32 + MethodCONNECT // 64 + MethodOPTIONS // 128 + MethodTRACE // 256 +) + +func methodToBit(method string) MethodMask { + switch method { + case "GET": + return MethodGET + case "HEAD": + return MethodHEAD + case "POST": + return MethodPOST + case "PUT": + return MethodPUT + case "PATCH": + return MethodPATCH + case "DELETE": + return MethodDELETE + case "CONNECT": + return MethodCONNECT + case "OPTIONS": + return MethodOPTIONS + case "TRACE": + return MethodTRACE + default: + return 0 + } +} + +// EventFields holds event data extracted once per event for pre-filtering. +// Passed by value (stack-allocated, ~28 bytes) — extracted once before the +// rule loop, reused across all rules. +type EventFields struct { + Path string // file/exec path (empty if not applicable) + DstPort uint16 // destination port from network/SSH event + Dir Direction // pre-computed from HTTP direction string + MethodBit MethodMask // pre-computed from HTTP method string + PortEligible bool // true for SSH/network events (port filter applies) + Extracted bool // true after extractEventFields has run +} + +// SetDirection converts a direction string to its compact representation. +func (f *EventFields) SetDirection(s string) { + f.Dir = parseDirection(s) +} + +// SetMethod converts an HTTP method string to its bitmask representation. +func (f *EventFields) SetMethod(method string) { + f.MethodBit = methodToBit(method) +} + +// Params holds parsed, typed parameters for cheap pre-CEL filtering. +// Parsed once at rule binding time. A non-nil *Params always has at least +// one active filter. +type Params struct { + IgnorePrefixes []string // open, exec — skip if path starts with prefix + IncludePrefixes []string // open, exec — skip if path does NOT match any prefix + Ports []uint16 // SSH, network — skip if port is NOT in list + Dir Direction // HTTP — DirInbound or DirOutbound + MethodMask MethodMask // HTTP — bitmask of allowed methods +} + +// ParseWithDefaults merges pre-filter parameters from two sources: +// - ruleState: defaults from the rule library YAML (Rule.State) +// - bindingParams: per-deployment overrides from the rule binding CRD +// +// Binding parameters override rule state for the same key. +// Returns nil if no pre-filterable parameters are present. +func ParseWithDefaults(ruleState map[string]any, bindingParams map[string]any) *Params { + if len(ruleState) == 0 && len(bindingParams) == 0 { + return nil + } + + merged := make(map[string]any, len(ruleState)+len(bindingParams)) + for k, v := range ruleState { + merged[k] = v + } + for k, v := range bindingParams { + merged[k] = v // binding overrides state + } + + p := &Params{} + hasFilter := false + + if v, ok := toStringSlice(merged["ignorePrefixes"]); ok && len(v) > 0 { + p.IgnorePrefixes = ensureTrailingSlash(v) + hasFilter = true + } + + if v, ok := toStringSlice(merged["includePrefixes"]); ok && len(v) > 0 { + p.IncludePrefixes = ensureTrailingSlash(v) + hasFilter = true + } + + if v, ok := toUint16Slice(merged["ports"]); ok && len(v) > 0 { + p.Ports = v + hasFilter = true + } + + if v, ok := merged["direction"].(string); ok && v != "" { + p.Dir = parseDirection(strings.ToLower(v)) + if p.Dir != DirNone { + hasFilter = true + } + } + + if v, ok := toStringSlice(merged["methods"]); ok && len(v) > 0 { + for _, m := range v { + p.MethodMask |= methodToBit(strings.ToUpper(m)) + } + if p.MethodMask != 0 { + hasFilter = true + } + } + + if !hasFilter { + return nil + } + return p +} + +// ShouldSkip returns true if the event should be skipped. +// Hot path — integer/bitmask comparisons only, no allocations. +// Safe to call on nil receiver (returns false). +func (p *Params) ShouldSkip(e EventFields) bool { + if p == nil { + return false + } + + if p.Dir != DirNone && e.Dir != DirNone && e.Dir != p.Dir { + return true + } + + if p.MethodMask != 0 && e.MethodBit != 0 && p.MethodMask&e.MethodBit == 0 { + return true + } + + if e.Path != "" { + if len(p.IncludePrefixes) > 0 && !hasAnyPrefix(e.Path, p.IncludePrefixes) { + return true + } + if len(p.IgnorePrefixes) > 0 && hasAnyPrefix(e.Path, p.IgnorePrefixes) { + return true + } + } + + if e.PortEligible && len(p.Ports) > 0 && !slices.Contains(p.Ports, e.DstPort) { + return true + } + + return false +} + +func hasAnyPrefix(s string, prefixes []string) bool { + for _, p := range prefixes { + if strings.HasPrefix(s, p) { + return true + } + } + return false +} + +func toStringSlice(v interface{}) ([]string, bool) { + if v == nil { + return nil, false + } + switch val := v.(type) { + case []interface{}: + result := make([]string, 0, len(val)) + for _, item := range val { + if s, ok := item.(string); ok { + result = append(result, s) + } + } + return result, len(result) > 0 + case []string: + cp := make([]string, len(val)) + copy(cp, val) + return cp, len(val) > 0 + } + return nil, false +} + +func toUint16Slice(v interface{}) ([]uint16, bool) { + if v == nil { + return nil, false + } + switch vals := v.(type) { + case []interface{}: + result := make([]uint16, 0, len(vals)) + for _, item := range vals { + if f, ok := item.(float64); ok && f >= 0 && f <= 65535 { + result = append(result, uint16(f)) + } + } + return result, len(result) > 0 + case []uint16: + cp := make([]uint16, len(vals)) + copy(cp, vals) + return cp, len(vals) > 0 + } + return nil, false +} + +func ensureTrailingSlash(prefixes []string) []string { + result := make([]string, len(prefixes)) + for i, p := range prefixes { + if !strings.HasSuffix(p, "/") { + result[i] = p + "/" + } else { + result[i] = p + } + } + return result +} diff --git a/pkg/rulemanager/prefilter/prefilter_test.go b/pkg/rulemanager/prefilter/prefilter_test.go new file mode 100644 index 000000000..d51ff8b8e --- /dev/null +++ b/pkg/rulemanager/prefilter/prefilter_test.go @@ -0,0 +1,165 @@ +package prefilter + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseWithDefaults(t *testing.T) { + tests := []struct { + name string + ruleState map[string]any + bindingParams map[string]any + expect *Params + }{ + { + name: "both nil", + expect: nil, + }, + { + name: "binding params only — ignorePrefixes", + bindingParams: map[string]any{"ignorePrefixes": []interface{}{"/tmp", "/var/log"}}, + expect: &Params{IgnorePrefixes: []string{"/tmp/", "/var/log/"}}, + }, + { + name: "rule state only — direction and methods", + ruleState: map[string]any{"direction": "inbound", "methods": []interface{}{"POST", "PUT"}}, + expect: &Params{Dir: DirInbound, MethodMask: MethodPOST | MethodPUT}, + }, + { + name: "binding overrides rule state", + ruleState: map[string]any{"direction": "inbound"}, + bindingParams: map[string]any{"direction": "outbound"}, + expect: &Params{Dir: DirOutbound}, + }, + { + name: "merge: state has direction, binding has prefixes", + ruleState: map[string]any{"direction": "inbound", "methods": []interface{}{"POST"}}, + bindingParams: map[string]any{"ignorePrefixes": []interface{}{"/tmp"}}, + expect: &Params{Dir: DirInbound, MethodMask: MethodPOST, IgnorePrefixes: []string{"/tmp/"}}, + }, + { + name: "ports (float64 from JSON)", + bindingParams: map[string]any{"ports": []interface{}{float64(22), float64(2222)}}, + expect: &Params{Ports: []uint16{22, 2222}}, + }, + { + name: "direction normalized to lowercase", + ruleState: map[string]any{"direction": "Inbound"}, + expect: &Params{Dir: DirInbound}, + }, + { + name: "non-filterable keys only", + bindingParams: map[string]any{"enforceArgs": true, "additionalPaths": []interface{}{"/etc/shadow"}}, + expect: nil, + }, + { + name: "non-filterable in state, filterable in binding", + ruleState: map[string]any{"enforceArgs": true}, + bindingParams: map[string]any{"ignorePrefixes": []interface{}{"/tmp"}}, + expect: &Params{IgnorePrefixes: []string{"/tmp/"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseWithDefaults(tt.ruleState, tt.bindingParams) + if tt.expect == nil { + assert.Nil(t, got) + } else { + require.NotNil(t, got) + assert.Equal(t, tt.expect, got) + } + }) + } +} + +func TestShouldSkip(t *testing.T) { + tests := []struct { + name string + params *Params + event EventFields + want bool + }{ + // --- nil / empty --- + {"nil params", nil, EventFields{}, false}, + {"empty params", &Params{}, EventFields{Path: "/etc/passwd"}, false}, + + // --- ignorePrefixes --- + {"ignore match /tmp/", &Params{IgnorePrefixes: []string{"/tmp/", "/var/log/"}}, EventFields{Path: "/tmp/foo.txt"}, true}, + {"ignore match /var/log/", &Params{IgnorePrefixes: []string{"/tmp/", "/var/log/"}}, EventFields{Path: "/var/log/syslog"}, true}, + {"ignore no match", &Params{IgnorePrefixes: []string{"/tmp/", "/var/log/"}}, EventFields{Path: "/etc/passwd"}, false}, + {"ignore directory boundary", &Params{IgnorePrefixes: []string{"/tmp/"}}, EventFields{Path: "/tmpfiles/secret"}, false}, + {"ignore empty path skipped", &Params{IgnorePrefixes: []string{"/tmp/"}}, EventFields{}, false}, + + // --- includePrefixes --- + {"include match", &Params{IncludePrefixes: []string{"/etc/", "/usr/"}}, EventFields{Path: "/etc/passwd"}, false}, + {"include no match", &Params{IncludePrefixes: []string{"/etc/", "/usr/"}}, EventFields{Path: "/tmp/foo.txt"}, true}, + + // --- both prefixes (ignore wins) --- + {"both: ignored subdir", &Params{IncludePrefixes: []string{"/etc/"}, IgnorePrefixes: []string{"/etc/default/"}}, EventFields{Path: "/etc/default/grub"}, true}, + {"both: included not ignored", &Params{IncludePrefixes: []string{"/etc/"}, IgnorePrefixes: []string{"/etc/default/"}}, EventFields{Path: "/etc/passwd"}, false}, + + // --- direction --- + {"direction match", &Params{Dir: DirInbound}, EventFields{Dir: DirInbound}, false}, + {"direction mismatch", &Params{Dir: DirInbound}, EventFields{Dir: DirOutbound}, true}, + {"direction empty event (non-HTTP)", &Params{Dir: DirInbound}, EventFields{}, false}, + + // --- methods --- + {"method match POST", &Params{MethodMask: MethodPOST | MethodPUT}, EventFields{MethodBit: MethodPOST}, false}, + {"method match PUT", &Params{MethodMask: MethodPOST | MethodPUT}, EventFields{MethodBit: MethodPUT}, false}, + {"method mismatch GET", &Params{MethodMask: MethodPOST | MethodPUT}, EventFields{MethodBit: MethodGET}, true}, + {"method empty event (non-HTTP)", &Params{MethodMask: MethodPOST}, EventFields{}, false}, + + // --- ports (scoped to SSH/network via PortEligible) --- + {"port in monitored list (keep)", &Params{Ports: []uint16{22, 2222}}, EventFields{DstPort: 22, PortEligible: true}, false}, + {"port in monitored list single (keep)", &Params{Ports: []uint16{22}}, EventFields{DstPort: 22, PortEligible: true}, false}, + {"port not in monitored list (skip)", &Params{Ports: []uint16{22, 2222}}, EventFields{DstPort: 8080, PortEligible: true}, true}, + {"port zero (non-network)", &Params{Ports: []uint16{22}}, EventFields{}, false}, + {"HTTP port not filtered by ports", &Params{Ports: []uint16{22}}, EventFields{DstPort: 22, PortEligible: false}, false}, + + // --- combined: HTTP direction + method --- + {"HTTP direction mismatch skips", &Params{Dir: DirInbound, MethodMask: MethodPOST | MethodPUT}, EventFields{Dir: DirOutbound, MethodBit: MethodPOST}, true}, + {"HTTP method mismatch skips", &Params{Dir: DirInbound, MethodMask: MethodPOST | MethodPUT}, EventFields{Dir: DirInbound, MethodBit: MethodGET}, true}, + {"HTTP both pass", &Params{Dir: DirInbound, MethodMask: MethodPOST | MethodPUT}, EventFields{Dir: DirInbound, MethodBit: MethodPOST}, false}, + + // --- combined: file path include + ignore --- + {"file: not in include", &Params{IncludePrefixes: []string{"/etc/"}, IgnorePrefixes: []string{"/etc/default/"}}, EventFields{Path: "/tmp/foo"}, true}, + {"file: in include but ignored", &Params{IncludePrefixes: []string{"/etc/"}, IgnorePrefixes: []string{"/etc/default/"}}, EventFields{Path: "/etc/default/grub"}, true}, + {"file: in include not ignored", &Params{IncludePrefixes: []string{"/etc/"}, IgnorePrefixes: []string{"/etc/default/"}}, EventFields{Path: "/etc/shadow"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.params.ShouldSkip(tt.event)) + }) + } +} + +func BenchmarkShouldSkip_EarlyExit(b *testing.B) { + p := &Params{ + Dir: DirInbound, + MethodMask: MethodPOST | MethodPUT, + IgnorePrefixes: []string{"/tmp/", "/var/log/"}, + } + e := EventFields{Path: "/etc/passwd", Dir: DirOutbound, MethodBit: MethodGET} + b.ResetTimer() + for i := 0; i < b.N; i++ { + p.ShouldSkip(e) + } +} + +func BenchmarkShouldSkip_FullScan(b *testing.B) { + p := &Params{ + Dir: DirInbound, + MethodMask: MethodPOST | MethodPUT, + IgnorePrefixes: []string{"/tmp/", "/var/log/"}, + } + e := EventFields{Path: "/etc/passwd", Dir: DirInbound, MethodBit: MethodPOST} + b.ResetTimer() + for i := 0; i < b.N; i++ { + p.ShouldSkip(e) + } +} diff --git a/pkg/rulemanager/rule_manager.go b/pkg/rulemanager/rule_manager.go index f08cb8bfa..82c82a7b4 100644 --- a/pkg/rulemanager/rule_manager.go +++ b/pkg/rulemanager/rule_manager.go @@ -25,6 +25,7 @@ import ( "github.com/kubescape/node-agent/pkg/processtree" bindingcache "github.com/kubescape/node-agent/pkg/rulebindingmanager" "github.com/kubescape/node-agent/pkg/rulemanager/cel" + "github.com/kubescape/node-agent/pkg/rulemanager/prefilter" "github.com/kubescape/node-agent/pkg/rulemanager/profilehelper" "github.com/kubescape/node-agent/pkg/rulemanager/ruleadapters" "github.com/kubescape/node-agent/pkg/rulemanager/rulecooldown" @@ -203,6 +204,9 @@ func (rm *RuleManager) ReportEnrichedEvent(enrichedEvent *events.EnrichedEvent) } eventType := enrichedEvent.Event.GetEventType() + + var eventFields prefilter.EventFields + for _, rule := range rules { if !rule.Enabled { continue @@ -222,6 +226,17 @@ func (rm *RuleManager) ReportEnrichedEvent(enrichedEvent *events.EnrichedEvent) continue } + // Pre-filter: skip CEL evaluation if parsed parameters exclude this event. + if rule.Prefilter != nil { + if !eventFields.Extracted { + eventFields = extractEventFields(enrichedEvent.Event) + } + if rule.Prefilter.ShouldSkip(eventFields) { + rm.metrics.ReportRulePrefiltered(rule.Name) + continue + } + } + if rule.SupportPolicy && rm.validateRulePolicy(rule, enrichedEvent.Event, enrichedEvent.ContainerID) { continue } @@ -444,6 +459,44 @@ func (rm *RuleManager) evaluateHTTPPayloadState(state map[string]any, enrichedEv return stateCopy } +// extractEventFields extracts pre-filterable fields from an event. +// Called lazily on first rule with a prefilter — the returned value type is +// reused across all remaining rules. +func extractEventFields(event utils.K8sEvent) prefilter.EventFields { + f := prefilter.EventFields{Extracted: true} + + switch event.GetEventType() { + case utils.OpenEventType: + if e, ok := event.(utils.OpenEvent); ok { + f.Path = e.GetPath() + } + case utils.ExecveEventType: + if e, ok := event.(utils.ExecEvent); ok { + f.Path = e.GetExePath() + } + case utils.HTTPEventType: + if e, ok := event.(utils.HttpEvent); ok { + f.SetDirection(string(e.GetDirection())) + f.DstPort = e.GetDstPort() + if req := e.GetRequest(); req != nil { + f.SetMethod(req.Method) + } + } + case utils.NetworkEventType: + if e, ok := event.(utils.NetworkEvent); ok { + f.DstPort = e.GetDstPort() + f.PortEligible = true + } + case utils.SSHEventType: + if e, ok := event.(utils.SshEvent); ok { + f.DstPort = e.GetDstPort() + f.PortEligible = true + } + } + + return f +} + func cloneState(state map[string]any) map[string]any { if state == nil { return map[string]any{} diff --git a/pkg/rulemanager/types/v1/types.go b/pkg/rulemanager/types/v1/types.go index e91f28238..4658974ba 100644 --- a/pkg/rulemanager/types/v1/types.go +++ b/pkg/rulemanager/types/v1/types.go @@ -2,6 +2,7 @@ package types import ( "github.com/armosec/armoapi-go/armotypes" + "github.com/kubescape/node-agent/pkg/rulemanager/prefilter" "github.com/kubescape/node-agent/pkg/utils" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -32,6 +33,7 @@ type Rule struct { IsTriggerAlert bool `json:"isTriggerAlert" yaml:"isTriggerAlert"` MitreTactic string `json:"mitreTactic" yaml:"mitreTactic"` MitreTechnique string `json:"mitreTechnique" yaml:"mitreTechnique"` + Prefilter *prefilter.Params `json:"-" yaml:"-"` } type RuleExpressions struct {