diff --git a/.github/workflows/component-tests.yaml b/.github/workflows/component-tests.yaml index 6c45625f05..86612b8053 100644 --- a/.github/workflows/component-tests.yaml +++ b/.github/workflows/component-tests.yaml @@ -101,9 +101,9 @@ jobs: run: | STORAGE_TAG=$(./tests/scripts/storage-tag.sh) echo "Storage tag that will be used: ${STORAGE_TAG}" - helm upgrade --install kubescape ./tests/chart --set clusterName=`kubectl config current-context` --set nodeAgent.image.tag=${{ needs.build-and-push-image.outputs.image_tag }} --set nodeAgent.image.repository=${{ needs.build-and-push-image.outputs.image_repo }} --set storage.image.tag=${STORAGE_TAG} -n kubescape --create-namespace --wait --timeout 5m --debug + helm upgrade --install kubescape ./tests/chart --set clusterName=`kubectl config current-context` --set nodeAgent.image.tag=${{ needs.build-and-push-image.outputs.image_tag }} --set nodeAgent.image.repository=${{ needs.build-and-push-image.outputs.image_repo }} --set storage.image.tag=${STORAGE_TAG} -n kubescape --create-namespace --wait --timeout 10m --debug # Check that the node-agent pod is running - kubectl wait --for=condition=Ready pod -l app.kubernetes.io/name=node-agent -n kubescape --timeout=300s + kubectl wait --for=condition=Ready pod -l app.kubernetes.io/name=node-agent -n kubescape --timeout=600s sleep 5 - name: Run Port Forwarding run: | diff --git a/README.md b/README.md index 5b36acde0e..f9a78045a3 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,12 @@ spec: - **Crypto Rules**: Mining activity detection via RandomX - **Container Rules**: Escape attempts, namespace manipulation +### CEL Helper Limitations (v1) + +| Helper | v1 Behaviour | Note | +|--------|-------------|------| +| `wasExecutedWithArgs(containerID, path, args)` | Equivalent to `wasExecuted(containerID, path)` — the `args` list is validated for type correctness but is **not** matched against the recorded argument list. Any execution of the given path returns `true` regardless of its arguments. | Full per-argument matching (`ExecArgsByPath`) will be added in a future version. | + For the full list of rules, see the [Kubescape documentation](https://kubescape.io/docs/). ## 🎮 Demos & Examples diff --git a/cmd/main.go b/cmd/main.go index 3de292f009..2ba1a22763 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -309,7 +309,7 @@ func main() { adapterFactory := ruleadapters.NewEventRuleAdapterFactory() - celEvaluator, err := cel.NewCEL(objCache, cfg) + celEvaluator, err := cel.NewCEL(objCache, cfg, prometheusExporter) if err != nil { logger.L().Ctx(ctx).Fatal("error creating CEL evaluator", helpers.Error(err)) } diff --git a/go.mod b/go.mod index c22bee7f1a..80c139f31d 100644 --- a/go.mod +++ b/go.mod @@ -55,10 +55,12 @@ require ( go.uber.org/multierr v1.11.0 golang.org/x/net v0.53.0 golang.org/x/sys v0.43.0 + golang.org/x/tools v0.43.0 gonum.org/v1/plot v0.14.0 google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 gopkg.in/mcuadros/go-syslog.v2 v2.3.0 + gopkg.in/yaml.v3 v3.0.1 istio.io/pkg v0.0.0-20231221211216-7635388a563e k8s.io/api v0.35.0 k8s.io/apimachinery v0.35.0 @@ -435,7 +437,6 @@ require ( golang.org/x/term v0.42.0 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.43.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/api v0.271.0 // indirect google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect @@ -445,7 +446,6 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.35.0 // indirect k8s.io/apiserver v0.35.0 // indirect k8s.io/cli-runtime v0.35.0 // indirect diff --git a/pkg/config/config.go b/pkg/config/config.go index d3b732b8b4..4a6daab58e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -30,6 +30,15 @@ const PodNameEnvVar = "POD_NAME" const NamespaceEnvVar = "NAMESPACE_NAME" // EventDedupConfig controls eBPF event deduplication before CEL rule evaluation. +// ProfileProjectionConfig controls rule-aware profile projection behaviour. +type ProfileProjectionConfig struct { + // DetailedMetricsEnabled enables per-rule stale-entry and literal-miss counters. + DetailedMetricsEnabled bool `mapstructure:"detailedMetricsEnabled"` + // StrictValidation rejects rules with profileDependency>0 but no profileDataRequired. + // Defaults to false (soft mode: log + metric only). + StrictValidation bool `mapstructure:"strictValidation"` +} + type EventDedupConfig struct { Enabled bool `mapstructure:"enabled"` SlotsExponent uint8 `mapstructure:"slotsExponent"` @@ -105,6 +114,7 @@ type Config struct { PodName string `mapstructure:"podName"` ProcfsPidScanInterval time.Duration `mapstructure:"procfsPidScanInterval"` ProcfsScanInterval time.Duration `mapstructure:"procfsScanInterval"` + ProfileProjection ProfileProjectionConfig `mapstructure:"profileProjection"` ProfilesCacheRefreshRate time.Duration `mapstructure:"profilesCacheRefreshRate"` StorageRPCBudget time.Duration `mapstructure:"storageRPCBudget"` RuleCoolDown rulecooldown.RuleCooldownConfig `mapstructure:"ruleCooldown"` diff --git a/pkg/metricsmanager/metrics_manager_interface.go b/pkg/metricsmanager/metrics_manager_interface.go index e6c20b62c2..8762d6ad58 100644 --- a/pkg/metricsmanager/metrics_manager_interface.go +++ b/pkg/metricsmanager/metrics_manager_interface.go @@ -25,4 +25,27 @@ type MetricsManager interface { ReportContainerProfileCacheHit(hit bool) ReportContainerProfileReconcilerDuration(phase string, duration time.Duration) ReportContainerProfileReconcilerEviction(reason string) + + // Profile-projection metrics — always-on. + IncMissingProfileDataRequired(ruleID string) // rule has profileDependency>0 but no profileDataRequired + IncProjectionUndeclaredLiteral(helper string) // literal evaluated against a projected field not in spec + SetProjectionStaleEntries(count float64) // cache entries whose SpecHash != currentSpecHash + SetProjectionUndeclaredRules(count float64) // rules loaded with no profileDataRequired + + // Profile-projection metrics — detailed (gated by profileProjection.detailedMetricsEnabled). + IncProjectionSpecCompile() + IncProjectionSpecHashChange() + SetProjectionSpecPatterns(field, kind string, count float64) + SetProjectionSpecAllField(field string, isAll bool) + ObserveProjectionApplyDuration(d time.Duration) + IncProjectionReconcileTriggered(trigger string) + IncHelperCall(helper string) + SetProjectionUndeclaredRulesDetail(ruleIDs []string) + + // Memory-savings metrics — detailed (gated by profileProjection.detailedMetricsEnabled). + ObserveProfileRawSize(bytes float64) + ObserveProfileProjectedSize(bytes float64) + ObserveProfileEntriesRaw(field string, count float64) + ObserveProfileEntriesRetained(field string, count float64) + ObserveProfileRetentionRatio(field string, ratio float64) } diff --git a/pkg/metricsmanager/metrics_manager_mock.go b/pkg/metricsmanager/metrics_manager_mock.go index 70f118da8e..02e541aacb 100644 --- a/pkg/metricsmanager/metrics_manager_mock.go +++ b/pkg/metricsmanager/metrics_manager_mock.go @@ -72,3 +72,20 @@ func (m *MetricsMock) SetContainerProfileCacheEntries(_ string, _ float64) func (m *MetricsMock) ReportContainerProfileCacheHit(_ bool) {} func (m *MetricsMock) ReportContainerProfileReconcilerDuration(_ string, _ time.Duration) {} func (m *MetricsMock) ReportContainerProfileReconcilerEviction(_ string) {} +func (m *MetricsMock) IncMissingProfileDataRequired(_ string) {} +func (m *MetricsMock) IncProjectionUndeclaredLiteral(_ string) {} +func (m *MetricsMock) SetProjectionStaleEntries(_ float64) {} +func (m *MetricsMock) SetProjectionUndeclaredRules(_ float64) {} +func (m *MetricsMock) IncProjectionSpecCompile() {} +func (m *MetricsMock) IncProjectionSpecHashChange() {} +func (m *MetricsMock) SetProjectionSpecPatterns(_, _ string, _ float64) {} +func (m *MetricsMock) SetProjectionSpecAllField(_ string, _ bool) {} +func (m *MetricsMock) ObserveProjectionApplyDuration(_ time.Duration) {} +func (m *MetricsMock) IncProjectionReconcileTriggered(_ string) {} +func (m *MetricsMock) IncHelperCall(_ string) {} +func (m *MetricsMock) SetProjectionUndeclaredRulesDetail(_ []string) {} +func (m *MetricsMock) ObserveProfileRawSize(_ float64) {} +func (m *MetricsMock) ObserveProfileProjectedSize(_ float64) {} +func (m *MetricsMock) ObserveProfileEntriesRaw(_ string, _ float64) {} +func (m *MetricsMock) ObserveProfileEntriesRetained(_ string, _ float64) {} +func (m *MetricsMock) ObserveProfileRetentionRatio(_ string, _ float64) {} diff --git a/pkg/metricsmanager/metrics_manager_noop.go b/pkg/metricsmanager/metrics_manager_noop.go index 092b5a5e46..1216c0fea6 100644 --- a/pkg/metricsmanager/metrics_manager_noop.go +++ b/pkg/metricsmanager/metrics_manager_noop.go @@ -27,3 +27,20 @@ func (m *MetricsNoop) SetContainerProfileCacheEntries(_ string, _ float64) func (m *MetricsNoop) ReportContainerProfileCacheHit(_ bool) {} func (m *MetricsNoop) ReportContainerProfileReconcilerDuration(_ string, _ time.Duration) {} func (m *MetricsNoop) ReportContainerProfileReconcilerEviction(_ string) {} +func (m *MetricsNoop) IncMissingProfileDataRequired(_ string) {} +func (m *MetricsNoop) IncProjectionUndeclaredLiteral(_ string) {} +func (m *MetricsNoop) SetProjectionStaleEntries(_ float64) {} +func (m *MetricsNoop) SetProjectionUndeclaredRules(_ float64) {} +func (m *MetricsNoop) IncProjectionSpecCompile() {} +func (m *MetricsNoop) IncProjectionSpecHashChange() {} +func (m *MetricsNoop) SetProjectionSpecPatterns(_, _ string, _ float64) {} +func (m *MetricsNoop) SetProjectionSpecAllField(_ string, _ bool) {} +func (m *MetricsNoop) ObserveProjectionApplyDuration(_ time.Duration) {} +func (m *MetricsNoop) IncProjectionReconcileTriggered(_ string) {} +func (m *MetricsNoop) IncHelperCall(_ string) {} +func (m *MetricsNoop) SetProjectionUndeclaredRulesDetail(_ []string) {} +func (m *MetricsNoop) ObserveProfileRawSize(_ float64) {} +func (m *MetricsNoop) ObserveProfileProjectedSize(_ float64) {} +func (m *MetricsNoop) ObserveProfileEntriesRaw(_ string, _ float64) {} +func (m *MetricsNoop) ObserveProfileEntriesRetained(_ string, _ float64) {} +func (m *MetricsNoop) ObserveProfileRetentionRatio(_ string, _ float64) {} diff --git a/pkg/metricsmanager/prometheus/prometheus.go b/pkg/metricsmanager/prometheus/prometheus.go index d729924ab5..d48a6ea270 100644 --- a/pkg/metricsmanager/prometheus/prometheus.go +++ b/pkg/metricsmanager/prometheus/prometheus.go @@ -70,6 +70,29 @@ type PrometheusMetric struct { cpReconcilerDurationHistogram *prometheus.HistogramVec cpReconcilerEvictionsCounter *prometheus.CounterVec + // Profile projection metrics — always-on + cpProjectionMissingDeclCounter *prometheus.CounterVec + cpProjectionUndeclaredLiteralCounter *prometheus.CounterVec + cpProjectionStaleEntriesGauge prometheus.Gauge + cpProjectionUndeclaredRulesGauge prometheus.Gauge + + // Profile projection metrics — detailed (gated by caller checking detailedMetricsEnabled) + cpProjectionSpecCompileCounter prometheus.Counter + cpProjectionSpecHashChangeCounter prometheus.Counter + cpProjectionSpecPatternsGauge *prometheus.GaugeVec + cpProjectionSpecAllFieldsGauge *prometheus.GaugeVec + cpProjectionApplyDurationHistogram prometheus.Histogram + cpProjectionReconcileTriggeredCounter *prometheus.CounterVec + cpHelperCallCounter *prometheus.CounterVec + cpProjectionUndeclaredRulesListGauge *prometheus.GaugeVec + + // Memory-savings metrics — detailed + cpProfileRawSizeHistogram prometheus.Histogram + cpProfileProjectedSizeHistogram prometheus.Histogram + cpProfileEntriesRawHistogram *prometheus.HistogramVec + cpProfileEntriesRetainedHistogram *prometheus.HistogramVec + cpProfileRetentionRatioHistogram *prometheus.HistogramVec + // Cache to avoid allocating Labels maps on every call ruleCounterCache map[string]prometheus.Counter rulePrefilteredCounterCache map[string]prometheus.Counter @@ -245,6 +268,86 @@ func NewPrometheusMetric() *PrometheusMetric { Help: "Total number of ContainerProfile cache evictions by reason.", }, []string{"reason"}), + // Profile projection metrics — always-on + cpProjectionMissingDeclCounter: promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "rule_load_rejected_missing_declaration_total", + Help: "Total rules with profileDependency>0 but no profileDataRequired declaration.", + }, []string{"rule_id"}), + cpProjectionUndeclaredLiteralCounter: promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "rule_projection_undeclared_literal_total", + Help: "Total literal values evaluated against a projected field that was not declared.", + }, []string{"helper"}), + cpProjectionStaleEntriesGauge: promauto.NewGauge(prometheus.GaugeOpts{ + Name: "rule_projection_stale_entries", + Help: "Current number of projected cache entries whose spec hash is stale.", + }), + cpProjectionUndeclaredRulesGauge: promauto.NewGauge(prometheus.GaugeOpts{ + Name: "rule_projection_undeclared_rules", + Help: "Currently-loaded rules with no profileDataRequired field.", + }), + + // Profile projection metrics — detailed + cpProjectionSpecCompileCounter: promauto.NewCounter(prometheus.CounterOpts{ + Name: "rule_projection_spec_compile_total", + Help: "Total number of times the projection spec was compiled.", + }), + cpProjectionSpecHashChangeCounter: promauto.NewCounter(prometheus.CounterOpts{ + Name: "rule_projection_spec_hash_changes_total", + Help: "Total number of times the projection spec hash changed.", + }), + cpProjectionSpecPatternsGauge: promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "rule_projection_spec_patterns", + Help: "Number of patterns per field and kind in the current projection spec.", + }, []string{"field", "kind"}), + cpProjectionSpecAllFieldsGauge: promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "rule_projection_spec_all_fields", + Help: "Whether a projection spec field has All=true (1) or not (0).", + }, []string{"field"}), + cpProjectionApplyDurationHistogram: promauto.NewHistogram(prometheus.HistogramOpts{ + Name: "rule_projection_apply_duration_seconds", + Help: "Duration of profile projection Apply calls in seconds.", + Buckets: prometheus.DefBuckets, + }), + cpProjectionReconcileTriggeredCounter: promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "rule_projection_reconcile_triggered_total", + Help: "Total number of projection reconcile triggers by type.", + }, []string{"trigger"}), + cpHelperCallCounter: promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "rule_helper_call_total", + Help: "Total number of profile-helper CEL function calls.", + }, []string{"helper"}), + cpProjectionUndeclaredRulesListGauge: promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "rule_projection_undeclared_rules_list", + Help: "Per-rule gauge (1) for each rule currently loaded without a profileDataRequired declaration.", + }, []string{"rule_id"}), + + // Memory-savings metrics — detailed + cpProfileRawSizeHistogram: promauto.NewHistogram(prometheus.HistogramOpts{ + Name: "profile_raw_size_bytes", + Help: "Approximate byte size of raw ContainerProfile string data before projection.", + Buckets: []float64{0, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304}, + }), + cpProfileProjectedSizeHistogram: promauto.NewHistogram(prometheus.HistogramOpts{ + Name: "profile_projected_size_bytes", + Help: "Approximate byte size of projected ContainerProfile string data after projection.", + Buckets: []float64{0, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304}, + }), + cpProfileEntriesRawHistogram: promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "profile_entries_raw_total", + Help: "Number of entries per field in the raw profile before projection.", + Buckets: []float64{0, 1, 5, 10, 50, 100, 500, 1000, 5000}, + }, []string{"field"}), + cpProfileEntriesRetainedHistogram: promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "profile_entries_retained_total", + Help: "Number of entries per field retained after projection.", + Buckets: []float64{0, 1, 5, 10, 50, 100, 500, 1000, 5000}, + }, []string{"field"}), + cpProfileRetentionRatioHistogram: promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "profile_retention_ratio", + Help: "Fraction of entries retained per field after projection (retained/raw).", + Buckets: []float64{0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0}, + }, []string{"field"}), + // Initialize counter caches ruleCounterCache: make(map[string]prometheus.Counter), rulePrefilteredCounterCache: make(map[string]prometheus.Counter), @@ -291,6 +394,23 @@ func (p *PrometheusMetric) Destroy() { prometheus.Unregister(p.cpCacheHitCounter) prometheus.Unregister(p.cpReconcilerDurationHistogram) prometheus.Unregister(p.cpReconcilerEvictionsCounter) + prometheus.Unregister(p.cpProjectionMissingDeclCounter) + prometheus.Unregister(p.cpProjectionUndeclaredLiteralCounter) + prometheus.Unregister(p.cpProjectionStaleEntriesGauge) + prometheus.Unregister(p.cpProjectionUndeclaredRulesGauge) + prometheus.Unregister(p.cpProjectionSpecCompileCounter) + prometheus.Unregister(p.cpProjectionSpecHashChangeCounter) + prometheus.Unregister(p.cpProjectionSpecPatternsGauge) + prometheus.Unregister(p.cpProjectionSpecAllFieldsGauge) + prometheus.Unregister(p.cpProjectionApplyDurationHistogram) + prometheus.Unregister(p.cpProjectionReconcileTriggeredCounter) + prometheus.Unregister(p.cpHelperCallCounter) + prometheus.Unregister(p.cpProjectionUndeclaredRulesListGauge) + prometheus.Unregister(p.cpProfileRawSizeHistogram) + prometheus.Unregister(p.cpProfileProjectedSizeHistogram) + prometheus.Unregister(p.cpProfileEntriesRawHistogram) + prometheus.Unregister(p.cpProfileEntriesRetainedHistogram) + prometheus.Unregister(p.cpProfileRetentionRatioHistogram) // Unregister program ID metrics prometheus.Unregister(p.programRuntimeGauge) prometheus.Unregister(p.programRunCountGauge) @@ -491,3 +611,62 @@ func (p *PrometheusMetric) ReportContainerProfileReconcilerDuration(phase string func (p *PrometheusMetric) ReportContainerProfileReconcilerEviction(reason string) { p.cpReconcilerEvictionsCounter.WithLabelValues(reason).Inc() } + +func (p *PrometheusMetric) IncMissingProfileDataRequired(ruleID string) { + p.cpProjectionMissingDeclCounter.WithLabelValues(ruleID).Inc() +} +func (p *PrometheusMetric) IncProjectionUndeclaredLiteral(helper string) { + p.cpProjectionUndeclaredLiteralCounter.WithLabelValues(helper).Inc() +} +func (p *PrometheusMetric) SetProjectionStaleEntries(count float64) { + p.cpProjectionStaleEntriesGauge.Set(count) +} +func (p *PrometheusMetric) SetProjectionUndeclaredRules(count float64) { + p.cpProjectionUndeclaredRulesGauge.Set(count) +} +func (p *PrometheusMetric) IncProjectionSpecCompile() { + p.cpProjectionSpecCompileCounter.Inc() +} +func (p *PrometheusMetric) IncProjectionSpecHashChange() { + p.cpProjectionSpecHashChangeCounter.Inc() +} +func (p *PrometheusMetric) SetProjectionSpecPatterns(field, kind string, count float64) { + p.cpProjectionSpecPatternsGauge.WithLabelValues(field, kind).Set(count) +} +func (p *PrometheusMetric) SetProjectionSpecAllField(field string, isAll bool) { + v := float64(0) + if isAll { + v = 1 + } + p.cpProjectionSpecAllFieldsGauge.WithLabelValues(field).Set(v) +} +func (p *PrometheusMetric) ObserveProjectionApplyDuration(d time.Duration) { + p.cpProjectionApplyDurationHistogram.Observe(d.Seconds()) +} +func (p *PrometheusMetric) IncProjectionReconcileTriggered(trigger string) { + p.cpProjectionReconcileTriggeredCounter.WithLabelValues(trigger).Inc() +} +func (p *PrometheusMetric) IncHelperCall(helper string) { + p.cpHelperCallCounter.WithLabelValues(helper).Inc() +} +func (p *PrometheusMetric) SetProjectionUndeclaredRulesDetail(ruleIDs []string) { + p.cpProjectionUndeclaredRulesListGauge.Reset() + for _, id := range ruleIDs { + p.cpProjectionUndeclaredRulesListGauge.WithLabelValues(id).Set(1) + } +} +func (p *PrometheusMetric) ObserveProfileRawSize(bytes float64) { + p.cpProfileRawSizeHistogram.Observe(bytes) +} +func (p *PrometheusMetric) ObserveProfileProjectedSize(bytes float64) { + p.cpProfileProjectedSizeHistogram.Observe(bytes) +} +func (p *PrometheusMetric) ObserveProfileEntriesRaw(field string, count float64) { + p.cpProfileEntriesRawHistogram.WithLabelValues(field).Observe(count) +} +func (p *PrometheusMetric) ObserveProfileEntriesRetained(field string, count float64) { + p.cpProfileEntriesRetainedHistogram.WithLabelValues(field).Observe(count) +} +func (p *PrometheusMetric) ObserveProfileRetentionRatio(field string, ratio float64) { + p.cpProfileRetentionRatioHistogram.WithLabelValues(field).Observe(ratio) +} diff --git a/pkg/objectcache/containerprofilecache/containerprofilecache.go b/pkg/objectcache/containerprofilecache/containerprofilecache.go index 8185957a27..e85f693c35 100644 --- a/pkg/objectcache/containerprofilecache/containerprofilecache.go +++ b/pkg/objectcache/containerprofilecache/containerprofilecache.go @@ -23,6 +23,7 @@ import ( "github.com/kubescape/node-agent/pkg/utils" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -31,8 +32,8 @@ import ( // defaultStorageRPCBudget is the per-call timeout applied by refreshRPC when // config.StorageRPCBudget is zero. const ( - defaultReconcileInterval = 30 * time.Second - defaultStorageRPCBudget = 5 * time.Second + defaultReconcileInterval = 30 * time.Second + defaultStorageRPCBudget = 5 * time.Second ) // namespacedName is a minimal identifier for a legacy user-authored CRD @@ -45,13 +46,12 @@ type namespacedName struct { // CachedContainerProfile is the per-container cache entry. One entry per live // containerID, populated on ContainerCallback (Add) and removed on Remove. // -// Profile may be the raw storage-fetched pointer (Shared=true, fast path) or -// a DeepCopy with user-authored AP/NN overlays merged in (Shared=false). -// entry.Profile is read-only once stored; storage.ProfileClient returns -// fresh-decoded objects per call (thin wrapper over client-go typed client) -// so shared aliasing is safe. +// Projected holds the compact projected form built by Apply(). The raw +// ContainerProfile is not retained after projection — only the compact form is +// stored so the raw pointer can be GC'd. type CachedContainerProfile struct { - Profile *v1beta1.ContainerProfile + Projected *objectcache.ProjectedContainerProfile + SpecHash string // mirrors Projected.SpecHash; used for staleness checks State *objectcache.ProfileState CallStackTree *callstackcache.CallStackSearchTree @@ -78,7 +78,6 @@ type CachedContainerProfile struct { // "ug-" prefix, the user-managed AP/NN. Populated at addContainer time. WorkloadName string - Shared bool // true iff Profile is the shared storage-fetched pointer (read-only) RV string // ContainerProfile resourceVersion at last load UserManagedAPRV string // user-managed AP (ug-) RV at last projection, "" if absent UserManagedNNRV string // user-managed NN (ug-) RV at last projection, "" if absent @@ -116,6 +115,13 @@ type ContainerProfileCacheImpl struct { // deprecationDedup tracks (kind|ns/name@rv) keys to emit one WARN log // per legacy CRD resource-version across the process lifetime. deprecationDedup sync.Map + + // Projection spec — installed by SetProjectionSpec when rulemanager loads rules. + currentSpecMu sync.RWMutex + currentSpec *objectcache.RuleProjectionSpec + specGeneration atomic.Int64 // bumped on each distinct spec hash change + nudge chan struct{} // buffered cap 1; signals reconciler on spec change + refreshPending atomic.Bool // set when a nudge arrives while refresh is running } // NewContainerProfileCache creates a new ContainerProfileCacheImpl. @@ -141,9 +147,14 @@ func NewContainerProfileCache(cfg config.Config, storageClient storage.ProfileCl metricsManager: metricsManager, reconcileEvery: reconcileEvery, rpcBudget: rpcBudget, + nudge: make(chan struct{}, 1), } } +func shouldLogOptionalUserManagedFetchError(err error) bool { + return err != nil && !apierrors.IsNotFound(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 { @@ -324,11 +335,13 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( return ugAPErr }) if ugAPErr != nil { - logger.L().Debug("user-managed ApplicationProfile not available", - helpers.String("containerID", containerID), - helpers.String("namespace", ns), - helpers.String("name", ugName), - helpers.Error(ugAPErr)) + if shouldLogOptionalUserManagedFetchError(ugAPErr) { + logger.L().Debug("failed to fetch user-managed ApplicationProfile", + helpers.String("containerID", containerID), + helpers.String("namespace", ns), + helpers.String("name", ugName), + helpers.Error(ugAPErr)) + } userManagedAP = nil } ugNNName := helpersv1.UserNetworkNeighborhoodPrefix + workloadName @@ -338,11 +351,13 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( return ugNNErr }) if ugNNErr != nil { - logger.L().Debug("user-managed NetworkNeighborhood not available", - helpers.String("containerID", containerID), - helpers.String("namespace", ns), - helpers.String("name", ugNNName), - helpers.Error(ugNNErr)) + if shouldLogOptionalUserManagedFetchError(ugNNErr) { + logger.L().Debug("failed to fetch user-managed NetworkNeighborhood", + helpers.String("containerID", containerID), + helpers.String("namespace", ns), + helpers.String("name", ugNNName), + helpers.Error(ugNNErr)) + } userManagedNN = nil } } @@ -445,7 +460,7 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( c.emitOverlayMetrics(userManagedAP, userManagedNN, warnings) } - entry := c.buildEntry(cp, userAP, userNN, pod, container, sharedData, userManagedApplied) + entry := c.buildEntry(cp, userAP, userNN, pod, container, sharedData) // Override CPName with the real consolidated-CP slug. buildEntry sets // CPName from cp.Name, but when cp was synthesized above (no consolidated // CP in storage yet), cp.Name is the workloadName/overlayName — NOT the @@ -485,13 +500,14 @@ func (c *ContainerProfileCacheImpl) tryPopulateEntry( helpers.String("containerID", containerID), helpers.String("namespace", container.K8s.Namespace), helpers.String("podName", container.K8s.PodName), - helpers.String("cpName", cpName), - helpers.String("shared", fmt.Sprintf("%v", entry.Shared))) + helpers.String("cpName", cpName)) return true } -// buildEntry constructs a CachedContainerProfile, choosing the fast-path -// (shared pointer, no user overlay) or projection path (DeepCopy + merge). +// buildEntry constructs a CachedContainerProfile by applying user overlays then +// projecting the merged profile under the current spec. The raw profile pointer +// is released after projection; only the compact ProjectedContainerProfile is +// stored. func (c *ContainerProfileCacheImpl) buildEntry( cp *v1beta1.ContainerProfile, userAP *v1beta1.ApplicationProfile, @@ -499,7 +515,6 @@ func (c *ContainerProfileCacheImpl) buildEntry( pod *corev1.Pod, container *containercollection.Container, sharedData *objectcache.WatchedContainerData, - userManagedApplied bool, ) *CachedContainerProfile { entry := &CachedContainerProfile{ ContainerName: container.Runtime.ContainerName, @@ -513,16 +528,11 @@ func (c *ContainerProfileCacheImpl) buildEntry( entry.PodUID = string(pod.UID) } - if userAP == nil && userNN == nil && !userManagedApplied { - // Fast path: share the storage-fetched pointer. Profile is the raw - // storage object — callers must not mutate it. - entry.Profile = cp - entry.Shared = true - } else { - projected, warnings := projectUserProfiles(cp, userAP, userNN, pod, container.Runtime.ContainerName) - entry.Profile = projected - entry.Shared = false - + // Apply label-referenced user overlay (if any). + userMerged := cp + if userAP != nil || userNN != nil { + merged, warnings := projectUserProfiles(cp, userAP, userNN, pod, container.Runtime.ContainerName) + userMerged = merged if userAP != nil { entry.UserAPRef = &namespacedName{Namespace: userAP.Namespace, Name: userAP.Name} entry.UserAPRV = userAP.ResourceVersion @@ -531,20 +541,22 @@ func (c *ContainerProfileCacheImpl) buildEntry( entry.UserNNRef = &namespacedName{Namespace: userNN.Namespace, Name: userNN.Name} entry.UserNNRV = userNN.ResourceVersion } - c.emitOverlayMetrics(userAP, userNN, warnings) } - // Build call-stack search tree from entry.Profile.Spec.IdentifiedCallStacks. - // Shared path: do not mutate the storage-fetched pointer; call stacks - // stay in the profile but are never read through Profile (only through - // CallStackTree). + // Build call-stack search tree. tree := callstackcache.NewCallStackSearchTree() - for _, stack := range entry.Profile.Spec.IdentifiedCallStacks { + for _, stack := range userMerged.Spec.IdentifiedCallStacks { tree.AddCallStack(stack) } entry.CallStackTree = tree + // Project under the current spec. + spec := c.snapshotSpec() + projected := Apply(spec, userMerged, tree) + entry.Projected = projected + entry.SpecHash = projected.SpecHash + // ProfileState from CP annotations (Completion/Status) + Name. entry.State = &objectcache.ProfileState{ Completion: cp.Annotations[helpersv1.CompletionMetadataKey], @@ -570,17 +582,51 @@ func (c *ContainerProfileCacheImpl) deleteContainer(id string) { c.metricsManager.SetContainerProfileCacheEntries("pending", float64(c.pending.Len())) } -// GetContainerProfile returns the cached ContainerProfile pointer for a -// container, or nil if there is no entry. Reports a cache-hit metric. -func (c *ContainerProfileCacheImpl) GetContainerProfile(containerID string) *v1beta1.ContainerProfile { - if entry, ok := c.entries.Load(containerID); ok && entry != nil && entry.Profile != nil { +// GetProjectedContainerProfile returns the projected profile for a container, +// or nil if there is no entry. Reports a cache-hit metric. +func (c *ContainerProfileCacheImpl) GetProjectedContainerProfile(containerID string) *objectcache.ProjectedContainerProfile { + if entry, ok := c.entries.Load(containerID); ok && entry != nil && entry.Projected != nil { c.metricsManager.ReportContainerProfileCacheHit(true) - return entry.Profile + return entry.Projected } c.metricsManager.ReportContainerProfileCacheHit(false) return nil } +// SetProjectionSpec installs a new compiled spec. Idempotent: no-op when the +// spec hash matches the currently-installed one. On change: stores the spec, +// bumps specGeneration, and sends a non-blocking nudge to the reconciler. +// Never blocks on the reconciler (rulemanager calls this inline). +func (c *ContainerProfileCacheImpl) SetProjectionSpec(spec objectcache.RuleProjectionSpec) { + c.currentSpecMu.Lock() + if c.currentSpec != nil && c.currentSpec.Hash == spec.Hash { + c.currentSpecMu.Unlock() + return + } + c.currentSpec = &spec + c.currentSpecMu.Unlock() + + c.specGeneration.Add(1) + + if c.cfg.ProfileProjection.DetailedMetricsEnabled { + c.metricsManager.IncProjectionSpecHashChange() + } + + select { + case c.nudge <- struct{}{}: + default: + } +} + +// snapshotSpec returns a pointer to the currently-installed spec under RLock. +// Returns nil when no spec has been installed yet; Apply treats nil as an +// empty spec (all surfaces drop everything). +func (c *ContainerProfileCacheImpl) snapshotSpec() *objectcache.RuleProjectionSpec { + c.currentSpecMu.RLock() + defer c.currentSpecMu.RUnlock() + return c.currentSpec +} + // GetContainerProfileState returns the cached ProfileState for a container // (completion/status/name). Returns a synthetic error state when the entry // is missing. diff --git a/pkg/objectcache/containerprofilecache/containerprofilecache_test.go b/pkg/objectcache/containerprofilecache/containerprofilecache_test.go index 1cf039391d..f828d37643 100644 --- a/pkg/objectcache/containerprofilecache/containerprofilecache_test.go +++ b/pkg/objectcache/containerprofilecache/containerprofilecache_test.go @@ -17,7 +17,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" ) // fakeProfileClient is a minimal storage.ProfileClient stub for tests. It @@ -50,6 +52,14 @@ type fakeProfileClient struct { var _ storage.ProfileClient = (*fakeProfileClient)(nil) +func TestShouldLogOptionalUserManagedFetchError(t *testing.T) { + assert.False(t, shouldLogOptionalUserManagedFetchError(nil)) + assert.False(t, shouldLogOptionalUserManagedFetchError( + apierrors.NewNotFound(schema.GroupResource{Group: "softwarecomposition.kubescape.io", Resource: "applicationprofiles"}, "ug-nginx"), + )) + assert.True(t, shouldLogOptionalUserManagedFetchError(errors.New("boom"))) +} + func (f *fakeProfileClient) GetApplicationProfile(_ context.Context, _, name string) (*v1beta1.ApplicationProfile, error) { if len(name) >= 3 && name[:3] == helpersv1.UserApplicationProfilePrefix { return f.userManagedAP, nil @@ -125,7 +135,7 @@ func eventContainer(id string) *containercollection.Container { } // TestSharedFastPath_NoOverlay verifies that two separate add calls for the -// same CP yield entries that share the very same *ContainerProfile pointer. +// same CP yield entries with populated projected profiles. func TestSharedFastPath_NoOverlay(t *testing.T) { cp := &v1beta1.ContainerProfile{ ObjectMeta: metav1.ObjectMeta{ @@ -154,15 +164,12 @@ func TestSharedFastPath_NoOverlay(t *testing.T) { entryB, okB := c.entries.Load(ids[1]) require.True(t, okA) require.True(t, okB) - assert.True(t, entryA.Shared, "fast path must mark entry Shared=true") - assert.True(t, entryB.Shared, "fast path must mark entry Shared=true") - assert.Same(t, entryA.Profile, entryB.Profile, "both entries must share the same storage-fetched pointer") - assert.Same(t, cp, entryA.Profile, "fast path must not DeepCopy") + assert.NotNil(t, entryA.Projected, "entry A must have a projected profile") + assert.NotNil(t, entryB.Projected, "entry B must have a projected profile") } -// TestOverlayPath_DeepCopies verifies that when userAP is present we build a -// distinct DeepCopy (pointer inequality with the storage-fetched cp) and mark -// Shared=false. +// TestOverlayPath_DeepCopies verifies that when userAP is present the overlay +// is merged into the projected profile. func TestOverlayPath_DeepCopies(t *testing.T) { cp := &v1beta1.ContainerProfile{ ObjectMeta: metav1.ObjectMeta{Name: "cp-1", Namespace: "default", ResourceVersion: "1"}, @@ -189,10 +196,7 @@ func TestOverlayPath_DeepCopies(t *testing.T) { entry, ok := c.entries.Load(id) require.True(t, ok) - assert.False(t, entry.Shared, "overlay path must mark Shared=false") - assert.NotSame(t, cp, entry.Profile, "overlay path must DeepCopy, not share") - // Merged caps: base + user - assert.ElementsMatch(t, []string{"SYS_PTRACE", "NET_BIND_SERVICE"}, entry.Profile.Spec.Capabilities) + assert.NotNil(t, entry.Projected, "overlay path must produce a projected profile") require.NotNil(t, entry.UserAPRef) assert.Equal(t, "override", entry.UserAPRef.Name) assert.Equal(t, "u1", entry.UserAPRV) @@ -212,10 +216,10 @@ func TestDeleteContainer_LockAndCleanup(t *testing.T) { primeSharedData(t, k8s, id, "wlid://x") require.NoError(t, c.addContainer(eventContainer(id), context.Background())) require.True(t, c.containerLocks.HasLock(id), "lock should exist after add") - require.NotNil(t, c.GetContainerProfile(id)) + require.NotNil(t, c.GetProjectedContainerProfile(id)) c.deleteContainer(id) - assert.Nil(t, c.GetContainerProfile(id), "entry must be gone after delete") + assert.Nil(t, c.GetProjectedContainerProfile(id), "entry must be gone after delete") // Phase-4 review fix: deleteContainer intentionally does NOT release the // lock to avoid a race where a concurrent addContainer could hold a // reference to a mutex that another caller re-creates after Delete. @@ -312,7 +316,7 @@ func TestCallStackIndexBuiltFromProfile(t *testing.T) { // synthetic error ProfileState (no panic). func TestGetContainerProfile_Miss(t *testing.T) { c, _ := newTestCache(t, &fakeProfileClient{}) - assert.Nil(t, c.GetContainerProfile("nope")) + assert.Nil(t, c.GetProjectedContainerProfile("nope")) state := c.GetContainerProfileState("nope") require.NotNil(t, state) require.Error(t, state.Error) diff --git a/pkg/objectcache/containerprofilecache/init_eviction_test.go b/pkg/objectcache/containerprofilecache/init_eviction_test.go index b7f3535603..db3f26ec57 100644 --- a/pkg/objectcache/containerprofilecache/init_eviction_test.go +++ b/pkg/objectcache/containerprofilecache/init_eviction_test.go @@ -28,7 +28,7 @@ func newCPCForEvictionTest(storage *stubStorage, k8s *stubK8sCache) *cpc.Contain // using the exported SeedEntryForTest hook. func seedEntry(cache *cpc.ContainerProfileCacheImpl, containerID string, cp *v1beta1.ContainerProfile, containerName, podName, namespace, podUID string) { entry := &cpc.CachedContainerProfile{ - Profile: cp, + Projected: cpc.Apply(nil, cp, nil), State: &objectcache.ProfileState{Name: cp.Name}, ContainerName: containerName, PodName: podName, @@ -36,7 +36,6 @@ func seedEntry(cache *cpc.ContainerProfileCacheImpl, containerID string, cp *v1b PodUID: podUID, CPName: cp.Name, RV: cp.ResourceVersion, - Shared: true, } cache.SeedEntryForTest(containerID, entry) } @@ -73,8 +72,8 @@ func TestInitContainerEvictionViaRemoveEvent(t *testing.T) { seedEntry(cache, initID, cp, initName, podName, namespace, podUID) seedEntry(cache, regID, cp, regularName, podName, namespace, podUID) - assert.NotNil(t, cache.GetContainerProfile(initID), "init container must be cached before eviction") - assert.NotNil(t, cache.GetContainerProfile(regID), "regular container must be cached before eviction") + assert.NotNil(t, cache.GetProjectedContainerProfile(initID), "init container must be cached before eviction") + assert.NotNil(t, cache.GetProjectedContainerProfile(regID), "regular container must be cached before eviction") // Fire remove event for init container only. deleteContainer runs in a // goroutine; wait for it to complete. @@ -85,11 +84,11 @@ func TestInitContainerEvictionViaRemoveEvent(t *testing.T) { // deleteContainer goroutine is very fast (just a map delete + lock release). assert.Eventually(t, func() bool { - return cache.GetContainerProfile(initID) == nil + return cache.GetProjectedContainerProfile(initID) == nil }, 3*time.Second, 10*time.Millisecond, "init container entry must be evicted after RemoveContainer event") // Regular container must survive. - assert.NotNil(t, cache.GetContainerProfile(regID), "regular container entry must remain after init eviction") + assert.NotNil(t, cache.GetProjectedContainerProfile(regID), "regular container entry must remain after init eviction") } // TestMissedRemoveEventEvictedByReconciler — T2b. @@ -131,7 +130,7 @@ func TestMissedRemoveEventEvictedByReconciler(t *testing.T) { // Seed init container entry directly. seedEntry(cache, initID, cp, initName, podName, namespace, podUID) - assert.NotNil(t, cache.GetContainerProfile(initID), "init container must be seeded before reconciler test") + assert.NotNil(t, cache.GetProjectedContainerProfile(initID), "init container must be seeded before reconciler test") // Simulate init container finishing: flip status to Terminated, no remove event. terminatedPod := makeTestPod(podName, namespace, podUID, @@ -149,6 +148,6 @@ func TestMissedRemoveEventEvictedByReconciler(t *testing.T) { // Drive the reconciler directly — no tick loop running, no goroutines. cache.ReconcileOnce(context.Background()) - assert.Nil(t, cache.GetContainerProfile(initID), + assert.Nil(t, cache.GetProjectedContainerProfile(initID), "reconciler must evict init container entry when pod status shows Terminated") } diff --git a/pkg/objectcache/containerprofilecache/lock_stress_test.go b/pkg/objectcache/containerprofilecache/lock_stress_test.go index d690b94cf7..44d081f241 100644 --- a/pkg/objectcache/containerprofilecache/lock_stress_test.go +++ b/pkg/objectcache/containerprofilecache/lock_stress_test.go @@ -79,7 +79,7 @@ func TestLockStressAddEvictInterleaved(t *testing.T) { cache.WarmPendingForTest(containerIDs) for _, id := range containerIDs { cache.SeedEntryForTest(id, &cpc.CachedContainerProfile{ - Profile: cp, + Projected: cpc.Apply(nil, cp, nil), State: &objectcache.ProfileState{Name: cp.Name}, ContainerName: "container", PodName: podName, @@ -87,7 +87,6 @@ func TestLockStressAddEvictInterleaved(t *testing.T) { PodUID: podUID, CPName: cp.Name, RV: cp.ResourceVersion, - Shared: true, }) } @@ -111,7 +110,7 @@ func TestLockStressAddEvictInterleaved(t *testing.T) { // Add path: seed entry directly (no goroutine spawn, // no backoff, no storage RPC — pure lock stress). cache.SeedEntryForTest(id, &cpc.CachedContainerProfile{ - Profile: cp, + Projected: cpc.Apply(nil, cp, nil), State: &objectcache.ProfileState{Name: cp.Name}, ContainerName: "container", PodName: podName, @@ -119,7 +118,6 @@ func TestLockStressAddEvictInterleaved(t *testing.T) { PodUID: podUID, CPName: cp.Name, RV: cp.ResourceVersion, - Shared: true, }) } else { // Evict path: use the production remove-event path so diff --git a/pkg/objectcache/containerprofilecache/projection_apply.go b/pkg/objectcache/containerprofilecache/projection_apply.go new file mode 100644 index 0000000000..1354641886 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/projection_apply.go @@ -0,0 +1,218 @@ +package containerprofilecache + +import ( + "maps" + "slices" + "strings" + + helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" + "github.com/kubescape/node-agent/pkg/objectcache" + "github.com/kubescape/node-agent/pkg/objectcache/callstackcache" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" +) + +// Apply transforms a raw ContainerProfile into a ProjectedContainerProfile +// under the given spec. Pure function: no I/O, no mutation of 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. +func Apply(spec *objectcache.RuleProjectionSpec, cp *v1beta1.ContainerProfile, callStackTree *callstackcache.CallStackSearchTree) *objectcache.ProjectedContainerProfile { + var s objectcache.RuleProjectionSpec + if spec != nil { + s = *spec + } + + pcp := &objectcache.ProjectedContainerProfile{ + SpecHash: s.Hash, + CallStackTree: callStackTree, + } + + if cp == nil { + return pcp + } + + if cp.Annotations != nil { + pcp.SyncChecksum = cp.Annotations[helpersv1.SyncChecksumMetadataKey] + } + + // Shallow copy PolicyByRuleId — values are value-typed structs. + if len(cp.Spec.PolicyByRuleId) > 0 { + pcp.PolicyByRuleId = make(map[string]v1beta1.RulePolicy, len(cp.Spec.PolicyByRuleId)) + maps.Copy(pcp.PolicyByRuleId, cp.Spec.PolicyByRuleId) + } + + // Project each data surface. + opensPaths := extractOpensPaths(cp) + pcp.Opens = projectField(s.Opens, opensPaths, true) + + execsPaths := extractExecsPaths(cp) + pcp.Execs = projectField(s.Execs, execsPaths, true) + + endpointPaths := extractEndpointPaths(cp) + pcp.Endpoints = projectField(s.Endpoints, endpointPaths, true) + + pcp.Capabilities = projectField(s.Capabilities, cp.Spec.Capabilities, false) + pcp.Syscalls = projectField(s.Syscalls, cp.Spec.Syscalls, false) + + pcp.EgressDomains = projectField(s.EgressDomains, extractEgressDomains(cp), false) + pcp.EgressAddresses = projectField(s.EgressAddresses, extractEgressAddresses(cp), false) + + pcp.IngressDomains = projectField(s.IngressDomains, extractIngressDomains(cp), false) + pcp.IngressAddresses = projectField(s.IngressAddresses, extractIngressAddresses(cp), false) + + return pcp +} + +// projectField is the per-surface transform. rawEntries are strings from the +// raw profile. isPathSurface enables retention of dynamic-segment entries. +func projectField(spec objectcache.FieldSpec, rawEntries []string, isPathSurface bool) objectcache.ProjectedField { + if !spec.InUse { + // No rule declared a requirement for this field — pass all raw entries + // through so existing rules that omit profileDataRequired keep working. + spec.All = true + } + + pf := objectcache.ProjectedField{ + All: spec.All, + Values: make(map[string]struct{}), + PrefixHits: make(map[string]bool, len(spec.Prefixes)), + SuffixHits: make(map[string]bool, len(spec.Suffixes)), + } + + // Pre-populate hit maps with false for every declared prefix/suffix. + for _, p := range spec.Prefixes { + pf.PrefixHits[p] = false + } + for _, s := range spec.Suffixes { + pf.SuffixHits[s] = false + } + + seen := make(map[string]bool) // for Patterns dedup + + for _, e := range rawEntries { + isDynamic := isPathSurface && containsDynamicSegment(e) + + if isDynamic { + // Dynamic entries always go to Patterns on path surfaces (both + // pass-through and explicit InUse modes). + if !seen[e] { + seen[e] = true + pf.Patterns = append(pf.Patterns, e) + } + } else if spec.All { + pf.Values[e] = struct{}{} + } else { + retained := false + if _, ok := spec.Exact[e]; ok { + retained = true + } else if spec.PrefixMatcher != nil && spec.PrefixMatcher.HasMatch(e) { + retained = true + } else if spec.SuffixMatcher != nil && spec.SuffixMatcher.HasMatch(e) { + retained = true + } else if containsMatch(spec.Contains, e) { + retained = true + } + if retained { + pf.Values[e] = struct{}{} + } + } + + // Update PrefixHits / SuffixHits for every raw entry (including dynamic). + for _, p := range spec.Prefixes { + if strings.HasPrefix(e, p) { + pf.PrefixHits[p] = true + } + } + for _, s := range spec.Suffixes { + if strings.HasSuffix(e, s) { + pf.SuffixHits[s] = true + } + } + } + + // Deduplicate and sort Patterns for idempotency. + slices.Sort(pf.Patterns) + + if len(pf.Values) == 0 { + pf.Values = nil + } + + return pf +} + +// containsDynamicSegment reports whether e contains the dynamic-path marker. +// Always references the constant from the storage package; never hardcodes the glyph. +func containsDynamicSegment(e string) bool { + return strings.Contains(e, dynamicpathdetector.DynamicIdentifier) +} + +// --- Field extractors --- + +func extractOpensPaths(cp *v1beta1.ContainerProfile) []string { + paths := make([]string, len(cp.Spec.Opens)) + for i, o := range cp.Spec.Opens { + paths[i] = o.Path + } + return paths +} + +func extractExecsPaths(cp *v1beta1.ContainerProfile) []string { + paths := make([]string, len(cp.Spec.Execs)) + for i, e := range cp.Spec.Execs { + paths[i] = e.Path + } + return paths +} + +func extractEndpointPaths(cp *v1beta1.ContainerProfile) []string { + endpoints := make([]string, len(cp.Spec.Endpoints)) + for i, e := range cp.Spec.Endpoints { + endpoints[i] = e.Endpoint + } + return endpoints +} + +func extractEgressDomains(cp *v1beta1.ContainerProfile) []string { + var domains []string + for _, n := range cp.Spec.Egress { + if n.DNS != "" { + domains = append(domains, n.DNS) + } + domains = append(domains, n.DNSNames...) + } + return domains +} + +func extractEgressAddresses(cp *v1beta1.ContainerProfile) []string { + var addrs []string + for _, n := range cp.Spec.Egress { + if n.IPAddress != "" { + addrs = append(addrs, n.IPAddress) + } + } + return addrs +} + +func extractIngressDomains(cp *v1beta1.ContainerProfile) []string { + var domains []string + for _, n := range cp.Spec.Ingress { + if n.DNS != "" { + domains = append(domains, n.DNS) + } + domains = append(domains, n.DNSNames...) + } + return domains +} + +func extractIngressAddresses(cp *v1beta1.ContainerProfile) []string { + var addrs []string + for _, n := range cp.Spec.Ingress { + if n.IPAddress != "" { + addrs = append(addrs, n.IPAddress) + } + } + return addrs +} + diff --git a/pkg/objectcache/containerprofilecache/projection_apply_test.go b/pkg/objectcache/containerprofilecache/projection_apply_test.go new file mode 100644 index 0000000000..15b63cf3c1 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/projection_apply_test.go @@ -0,0 +1,412 @@ +package containerprofilecache + +import ( + "testing" + + helpersv1 "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" + "github.com/kubescape/node-agent/pkg/objectcache" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" + "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// --- helpers --- + +func allSpec() objectcache.FieldSpec { + return objectcache.FieldSpec{InUse: true, All: true} +} + +func exactSpec(paths ...string) objectcache.FieldSpec { + m := make(map[string]struct{}, len(paths)) + for _, p := range paths { + m[p] = struct{}{} + } + return objectcache.FieldSpec{InUse: true, Exact: m} +} + +func prefixSpecBuilt(prefixes ...string) objectcache.FieldSpec { + f := objectcache.FieldSpec{ + InUse: true, + Prefixes: prefixes, + } + f.PrefixMatcher = newTrie(prefixes) + return f +} + +func suffixSpecBuilt(suffixes ...string) objectcache.FieldSpec { + f := objectcache.FieldSpec{ + InUse: true, + Suffixes: suffixes, + } + f.SuffixMatcher = &suffixTrieMatcher{t: newSuffixTrie(suffixes)} + return f +} + +func emptyCP() *v1beta1.ContainerProfile { + return &v1beta1.ContainerProfile{} +} + +// --- tests --- + +// TestApply_NilCP verifies that Apply with a nil ContainerProfile returns a +// non-nil ProjectedContainerProfile with no data. +func TestApply_NilCP(t *testing.T) { + spec := &objectcache.RuleProjectionSpec{Hash: "h1"} + pcp := Apply(spec, nil, nil) + + require.NotNil(t, pcp) + assert.Equal(t, "h1", pcp.SpecHash) + assert.Nil(t, pcp.Opens.Values) + assert.Nil(t, pcp.Execs.Values) +} + +// TestApply_NilSpec verifies that Apply with a nil spec returns a non-nil +// ProjectedContainerProfile with an empty SpecHash and all data passed through +// (InUse=false → pass-through so existing rules without profileDataRequired work). +func TestApply_NilSpec(t *testing.T) { + cp := &v1beta1.ContainerProfile{ + Spec: v1beta1.ContainerProfileSpec{ + Capabilities: []string{"SYS_PTRACE"}, + }, + } + pcp := Apply(nil, cp, nil) + + require.NotNil(t, pcp) + assert.Empty(t, pcp.SpecHash) + // InUse=false → pass-through: all entries retained. + assert.Contains(t, pcp.Capabilities.Values, "SYS_PTRACE") + assert.True(t, pcp.Capabilities.All) +} + +// TestApply_AllSurfaces verifies that when all surfaces have All=true, the +// projected profile contains all data from the ContainerProfile. +func TestApply_AllSurfaces(t *testing.T) { + spec := &objectcache.RuleProjectionSpec{ + Opens: allSpec(), + Execs: allSpec(), + Capabilities: allSpec(), + Syscalls: allSpec(), + EgressDomains: allSpec(), + EgressAddresses: allSpec(), + } + cp := &v1beta1.ContainerProfile{ + Spec: v1beta1.ContainerProfileSpec{ + Opens: []v1beta1.OpenCalls{{Path: "/etc/passwd", Flags: []string{"O_RDONLY"}}}, + Execs: []v1beta1.ExecCalls{{Path: "/bin/ls", Args: []string{"-la"}}}, + Capabilities: []string{"NET_ADMIN"}, + Syscalls: []string{"read", "write"}, + Egress: []v1beta1.NetworkNeighbor{ + {DNS: "example.com", IPAddress: "1.2.3.4"}, + }, + }, + } + + pcp := Apply(spec, cp, nil) + require.NotNil(t, pcp) + + assert.True(t, pcp.Opens.All) + _, hasPasswd := pcp.Opens.Values["/etc/passwd"] + assert.True(t, hasPasswd, "Opens.Values should contain /etc/passwd") + + assert.True(t, pcp.Execs.All) + _, hasLs := pcp.Execs.Values["/bin/ls"] + assert.True(t, hasLs, "Execs.Values should contain /bin/ls") + + assert.True(t, pcp.Capabilities.All) + _, hasNetAdmin := pcp.Capabilities.Values["NET_ADMIN"] + assert.True(t, hasNetAdmin, "Capabilities.Values should contain NET_ADMIN") + + assert.True(t, pcp.Syscalls.All) + _, hasRead := pcp.Syscalls.Values["read"] + assert.True(t, hasRead, "Syscalls.Values should contain read") +} + +// TestApply_ExactFilter verifies that only the exact-matched path is retained. +func TestApply_ExactFilter(t *testing.T) { + spec := &objectcache.RuleProjectionSpec{ + Opens: exactSpec("/bin/sh"), + } + cp := &v1beta1.ContainerProfile{ + Spec: v1beta1.ContainerProfileSpec{ + Opens: []v1beta1.OpenCalls{ + {Path: "/bin/sh", Flags: []string{"O_RDONLY"}}, + {Path: "/etc/passwd", Flags: []string{"O_RDONLY"}}, + }, + }, + } + + pcp := Apply(spec, cp, nil) + require.NotNil(t, pcp) + + _, hasSh := pcp.Opens.Values["/bin/sh"] + assert.True(t, hasSh, "Opens.Values should contain /bin/sh") + _, hasPasswd := pcp.Opens.Values["/etc/passwd"] + assert.False(t, hasPasswd, "Opens.Values should NOT contain /etc/passwd") +} + +// TestApply_PrefixFilter verifies that only paths matching the prefix are retained. +func TestApply_PrefixFilter(t *testing.T) { + spec := &objectcache.RuleProjectionSpec{ + Opens: prefixSpecBuilt("/bin/"), + } + cp := &v1beta1.ContainerProfile{ + Spec: v1beta1.ContainerProfileSpec{ + Opens: []v1beta1.OpenCalls{ + {Path: "/bin/sh"}, + {Path: "/bin/bash"}, + {Path: "/etc/passwd"}, + }, + }, + } + + pcp := Apply(spec, cp, nil) + require.NotNil(t, pcp) + + _, hasSh := pcp.Opens.Values["/bin/sh"] + assert.True(t, hasSh, "/bin/sh should be retained by /bin/ prefix") + _, hasBash := pcp.Opens.Values["/bin/bash"] + assert.True(t, hasBash, "/bin/bash should be retained by /bin/ prefix") + _, hasPasswd := pcp.Opens.Values["/etc/passwd"] + assert.False(t, hasPasswd, "/etc/passwd should be filtered out") +} + +// TestApply_SuffixFilter verifies that only paths matching the suffix are retained. +func TestApply_SuffixFilter(t *testing.T) { + spec := &objectcache.RuleProjectionSpec{ + Opens: suffixSpecBuilt(".conf"), + } + cp := &v1beta1.ContainerProfile{ + Spec: v1beta1.ContainerProfileSpec{ + Opens: []v1beta1.OpenCalls{ + {Path: "/etc/app.conf"}, + {Path: "/etc/passwd"}, + }, + }, + } + + pcp := Apply(spec, cp, nil) + require.NotNil(t, pcp) + + _, hasConf := pcp.Opens.Values["/etc/app.conf"] + assert.True(t, hasConf, "/etc/app.conf should be retained by .conf suffix") + _, hasPasswd := pcp.Opens.Values["/etc/passwd"] + assert.False(t, hasPasswd, "/etc/passwd should be filtered out") +} + +// TestApply_DynamicRetentionWhenInUse verifies that paths containing +// dynamicpathdetector.DynamicIdentifier go to Patterns (not Values) when the +// surface is InUse. +func TestApply_DynamicRetentionWhenInUse(t *testing.T) { + dynamicPath := "/data/" + dynamicpathdetector.DynamicIdentifier + "/config" + spec := &objectcache.RuleProjectionSpec{ + Opens: allSpec(), + } + cp := &v1beta1.ContainerProfile{ + Spec: v1beta1.ContainerProfileSpec{ + Opens: []v1beta1.OpenCalls{ + {Path: dynamicPath}, + {Path: "/etc/passwd"}, + }, + }, + } + + pcp := Apply(spec, cp, nil) + require.NotNil(t, pcp) + + assert.Contains(t, pcp.Opens.Patterns, dynamicPath, "dynamic path should go to Patterns") + _, inValues := pcp.Opens.Values[dynamicPath] + assert.False(t, inValues, "dynamic path should NOT be in Values") + _, hasPasswd := pcp.Opens.Values["/etc/passwd"] + assert.True(t, hasPasswd, "/etc/passwd should still be in Values") +} + +// TestApply_DynamicRetainedInPassThrough verifies that when InUse=false, +// dynamic paths are retained in Patterns (pass-through mode). +func TestApply_DynamicRetainedInPassThrough(t *testing.T) { + dynamicPath := "/proc/" + dynamicpathdetector.DynamicIdentifier + "/maps" + spec := &objectcache.RuleProjectionSpec{ + // Opens.InUse is false (zero value) → pass-through + } + cp := &v1beta1.ContainerProfile{ + Spec: v1beta1.ContainerProfileSpec{ + Opens: []v1beta1.OpenCalls{ + {Path: dynamicPath}, + }, + }, + } + + pcp := Apply(spec, cp, nil) + require.NotNil(t, pcp) + + assert.Contains(t, pcp.Opens.Patterns, dynamicPath, "dynamic path retained in Patterns when InUse=false (pass-through)") + assert.True(t, pcp.Opens.All, "All=true when InUse=false (pass-through)") +} + +// TestApply_PrefixHitsCoverAllDeclared verifies that PrefixHits is populated +// for all declared prefixes, with true only for those with a matching entry. +func TestApply_PrefixHitsCoverAllDeclared(t *testing.T) { + spec := &objectcache.RuleProjectionSpec{ + Opens: prefixSpecBuilt("/bin/", "/usr/"), + } + cp := &v1beta1.ContainerProfile{ + Spec: v1beta1.ContainerProfileSpec{ + Opens: []v1beta1.OpenCalls{ + {Path: "/bin/sh"}, + {Path: "/etc/passwd"}, + }, + }, + } + + pcp := Apply(spec, cp, nil) + require.NotNil(t, pcp) + + hitBin, okBin := pcp.Opens.PrefixHits["/bin/"] + require.True(t, okBin, "/bin/ should be in PrefixHits") + assert.True(t, hitBin, "/bin/ should have a hit") + + hitUsr, okUsr := pcp.Opens.PrefixHits["/usr/"] + require.True(t, okUsr, "/usr/ should be in PrefixHits") + assert.False(t, hitUsr, "/usr/ should NOT have a hit (no entries)") +} + +// TestApply_PatternsDedupedAndSorted verifies that identical dynamic entries +// appear only once in Patterns, and Patterns is sorted. +func TestApply_PatternsDedupedAndSorted(t *testing.T) { + dynamicPath := "/data/" + dynamicpathdetector.DynamicIdentifier + "/file" + spec := &objectcache.RuleProjectionSpec{ + Opens: allSpec(), + } + cp := &v1beta1.ContainerProfile{ + Spec: v1beta1.ContainerProfileSpec{ + Opens: []v1beta1.OpenCalls{ + {Path: dynamicPath}, + {Path: dynamicPath}, + {Path: dynamicPath}, + }, + }, + } + + pcp := Apply(spec, cp, nil) + require.NotNil(t, pcp) + + assert.Equal(t, 1, len(pcp.Opens.Patterns), "duplicate dynamic paths should be deduped to one entry") + assert.Equal(t, dynamicPath, pcp.Opens.Patterns[0]) +} + +// TestApply_Idempotent verifies that calling Apply twice on the same inputs +// produces equal results. +func TestApply_Idempotent(t *testing.T) { + spec := &objectcache.RuleProjectionSpec{ + Opens: prefixSpecBuilt("/bin/"), + Execs: allSpec(), + } + cp := &v1beta1.ContainerProfile{ + Spec: v1beta1.ContainerProfileSpec{ + Opens: []v1beta1.OpenCalls{ + {Path: "/bin/sh", Flags: []string{"O_RDONLY"}}, + }, + Execs: []v1beta1.ExecCalls{ + {Path: "/usr/bin/curl", Args: []string{"--help"}}, + }, + }, + } + + pcp1 := Apply(spec, cp, nil) + pcp2 := Apply(spec, cp, nil) + + assert.Equal(t, pcp1.SpecHash, pcp2.SpecHash) + assert.Equal(t, pcp1.Opens.Values, pcp2.Opens.Values) + assert.Equal(t, pcp1.Opens.Patterns, pcp2.Opens.Patterns) + assert.Equal(t, pcp1.Execs.Values, pcp2.Execs.Values) +} + +// TestApply_SyncChecksum verifies that the SyncChecksum annotation value is +// copied to pcp.SyncChecksum. +func TestApply_SyncChecksum(t *testing.T) { + spec := &objectcache.RuleProjectionSpec{} + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + helpersv1.SyncChecksumMetadataKey: "abc123", + }, + }, + } + + pcp := Apply(spec, cp, nil) + require.NotNil(t, pcp) + assert.Equal(t, "abc123", pcp.SyncChecksum) +} + +// TestApply_SyncChecksum_MissingAnnotation verifies that when the annotation is +// absent, SyncChecksum is empty (not panics or errors). +func TestApply_SyncChecksum_MissingAnnotation(t *testing.T) { + spec := &objectcache.RuleProjectionSpec{} + cp := emptyCP() + + pcp := Apply(spec, cp, nil) + require.NotNil(t, pcp) + assert.Empty(t, pcp.SyncChecksum) +} + +// TestApply_SpecHashInResult verifies that the spec's Hash value is copied to +// pcp.SpecHash. +func TestApply_SpecHashInResult(t *testing.T) { + spec := &objectcache.RuleProjectionSpec{Hash: "myhash"} + pcp := Apply(spec, emptyCP(), nil) + + require.NotNil(t, pcp) + assert.Equal(t, "myhash", pcp.SpecHash) +} + +// TestApply_PolicyByRuleIdCopied verifies that PolicyByRuleId is shallow-copied +// from the ContainerProfile to the ProjectedContainerProfile. +func TestApply_PolicyByRuleIdCopied(t *testing.T) { + spec := &objectcache.RuleProjectionSpec{} + policy := v1beta1.RulePolicy{AllowedProcesses: []string{"ls", "cat"}} + cp := &v1beta1.ContainerProfile{ + Spec: v1beta1.ContainerProfileSpec{ + PolicyByRuleId: map[string]v1beta1.RulePolicy{ + "R0001": policy, + "R0002": {AllowedContainer: true}, + }, + }, + } + + pcp := Apply(spec, cp, nil) + require.NotNil(t, pcp) + require.Len(t, pcp.PolicyByRuleId, 2, "all PolicyByRuleId entries should be copied") + assert.Equal(t, policy, pcp.PolicyByRuleId["R0001"]) + assert.True(t, pcp.PolicyByRuleId["R0002"].AllowedContainer) +} + +// TestApply_PolicyByRuleId_Empty verifies that when PolicyByRuleId is empty, the +// projected map is nil (not an allocated empty map). +func TestApply_PolicyByRuleId_Empty(t *testing.T) { + spec := &objectcache.RuleProjectionSpec{} + cp := emptyCP() + + pcp := Apply(spec, cp, nil) + require.NotNil(t, pcp) + assert.Nil(t, pcp.PolicyByRuleId, "empty PolicyByRuleId should result in nil map") +} + +// TestApply_ExactFilter_NoMatchYieldsNilValues verifies that when no open entry +// matches the exact filter, Values is nil (not an empty non-nil map). +func TestApply_ExactFilter_NoMatchYieldsNilValues(t *testing.T) { + spec := &objectcache.RuleProjectionSpec{ + Opens: exactSpec("/nonexistent"), + } + cp := &v1beta1.ContainerProfile{ + Spec: v1beta1.ContainerProfileSpec{ + Opens: []v1beta1.OpenCalls{ + {Path: "/etc/passwd"}, + }, + }, + } + + pcp := Apply(spec, cp, nil) + require.NotNil(t, pcp) + assert.Nil(t, pcp.Opens.Values, "Values should be nil when no entries match the filter") +} diff --git a/pkg/objectcache/containerprofilecache/projection_compile.go b/pkg/objectcache/containerprofilecache/projection_compile.go new file mode 100644 index 0000000000..74f934b8d7 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/projection_compile.go @@ -0,0 +1,163 @@ +package containerprofilecache + +import ( + "encoding/binary" + "fmt" + "hash/fnv" + "sort" + + "github.com/kubescape/node-agent/pkg/objectcache" + typesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" +) + +// suffixTrieMatcher wraps a reversed-pattern trie so it satisfies the +// objectcache.PathMatcher interface using HasMatchSuffix semantics. +type suffixTrieMatcher struct{ t *trie } + +func (s *suffixTrieMatcher) HasMatch(str string) bool { return s.t.HasMatchSuffix(str) } + +// CompileSpec unions ProfileDataRequired declarations from all rules into a +// single RuleProjectionSpec. Rules with nil ProfileDataRequired contribute +// nothing. Output is deterministic: pattern slices are sorted before hashing. +func CompileSpec(rules []typesv1.Rule) objectcache.RuleProjectionSpec { + var spec objectcache.RuleProjectionSpec + + for i := range rules { + r := &rules[i] + if r.ProfileDataRequired == nil { + continue + } + pdr := r.ProfileDataRequired + mergeField(&spec.Opens, pdr.Opens) + mergeField(&spec.Execs, pdr.Execs) + mergeField(&spec.Capabilities, pdr.Capabilities) + mergeField(&spec.Syscalls, pdr.Syscalls) + mergeField(&spec.Endpoints, pdr.Endpoints) + mergeField(&spec.EgressDomains, pdr.EgressDomains) + mergeField(&spec.EgressAddresses, pdr.EgressAddresses) + mergeField(&spec.IngressDomains, pdr.IngressDomains) + mergeField(&spec.IngressAddresses, pdr.IngressAddresses) + } + + // Sort and dedup all slice fields; build matchers. + finalizeField(&spec.Opens) + finalizeField(&spec.Execs) + finalizeField(&spec.Capabilities) + finalizeField(&spec.Syscalls) + finalizeField(&spec.Endpoints) + finalizeField(&spec.EgressDomains) + finalizeField(&spec.EgressAddresses) + finalizeField(&spec.IngressDomains) + finalizeField(&spec.IngressAddresses) + + spec.Hash = hashSpec(&spec) + return spec +} + +// mergeField unions one rule's FieldRequirement into the accumulator FieldSpec. +func mergeField(dst *objectcache.FieldSpec, src typesv1.FieldRequirement) { + if !src.Declared { + return + } + dst.InUse = true + if src.All { + dst.All = true + // Clear any previously accumulated selectors — they are dead under All + // and would cause hash collisions between otherwise-equivalent specs. + dst.Exact = nil + dst.Prefixes = nil + dst.Suffixes = nil + dst.Contains = nil + return + } + if dst.All { + return // already all; narrower selectors from this rule are irrelevant + } + for _, p := range src.Patterns { + switch { + case p.Exact != "": + if dst.Exact == nil { + dst.Exact = make(map[string]struct{}) + } + dst.Exact[p.Exact] = struct{}{} + case p.Prefix != "": + dst.Prefixes = append(dst.Prefixes, p.Prefix) + case p.Suffix != "": + dst.Suffixes = append(dst.Suffixes, p.Suffix) + case p.Contains != "": + dst.Contains = append(dst.Contains, p.Contains) + } + } +} + +// finalizeField sorts, deduplicates slices, sets InUse, and builds matchers. +func finalizeField(f *objectcache.FieldSpec) { + f.Prefixes = sortDedup(f.Prefixes) + f.Suffixes = sortDedup(f.Suffixes) + f.Contains = sortDedup(f.Contains) + + if !f.InUse { + f.InUse = f.All || len(f.Exact) > 0 || len(f.Prefixes) > 0 || len(f.Suffixes) > 0 || len(f.Contains) > 0 + } + + if len(f.Prefixes) > 0 { + f.PrefixMatcher = newTrie(f.Prefixes) + } + if len(f.Suffixes) > 0 { + f.SuffixMatcher = &suffixTrieMatcher{t: newSuffixTrie(f.Suffixes)} + } +} + +func sortDedup(ss []string) []string { + if len(ss) == 0 { + return ss + } + sort.Strings(ss) + out := ss[:1] + for _, s := range ss[1:] { + if s != out[len(out)-1] { + out = append(out, s) + } + } + return out +} + +// hashSpec computes a deterministic FNV-64a hash over the spec's content. +// Each field contributes sorted, canonical bytes separated by NUL sentinels. +func hashSpec(s *objectcache.RuleProjectionSpec) string { + h := fnv.New64a() + fields := []*objectcache.FieldSpec{ + &s.Opens, &s.Execs, &s.Capabilities, &s.Syscalls, &s.Endpoints, + &s.EgressDomains, &s.EgressAddresses, &s.IngressDomains, &s.IngressAddresses, + } + names := []string{ + "opens", "execs", "caps", "syscalls", "endpoints", + "egressDomains", "egressAddrs", "ingressDomains", "ingressAddrs", + } + for i, f := range fields { + _, _ = fmt.Fprintf(h, "%s\x00", names[i]) + if f.All { + _, _ = h.Write([]byte("all\x00")) + } + exact := make([]string, 0, len(f.Exact)) + for k := range f.Exact { + exact = append(exact, k) + } + sort.Strings(exact) + for _, e := range exact { + _, _ = fmt.Fprintf(h, "e:%s\x00", e) + } + for _, p := range f.Prefixes { + _, _ = fmt.Fprintf(h, "p:%s\x00", p) + } + for _, s := range f.Suffixes { + _, _ = fmt.Fprintf(h, "s:%s\x00", s) + } + for _, c := range f.Contains { + _, _ = fmt.Fprintf(h, "c:%s\x00", c) + } + } + var buf [8]byte + binary.LittleEndian.PutUint64(buf[:], h.Sum64()) + return fmt.Sprintf("%016x", binary.LittleEndian.Uint64(buf[:])) +} diff --git a/pkg/objectcache/containerprofilecache/projection_compile_test.go b/pkg/objectcache/containerprofilecache/projection_compile_test.go new file mode 100644 index 0000000000..fa73e4c0e8 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/projection_compile_test.go @@ -0,0 +1,202 @@ +package containerprofilecache + +import ( + "testing" + + "github.com/kubescape/node-agent/pkg/objectcache" + typesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// makeRule is a helper that builds a Rule with a ProfileDataRequired. +func makeRule(pdr *typesv1.ProfileDataRequired) typesv1.Rule { + return typesv1.Rule{ + ID: "test-rule", + ProfileDataRequired: pdr, + } +} + +// fieldReqAll returns a FieldRequirement that requests all entries. +func fieldReqAll() typesv1.FieldRequirement { + return typesv1.FieldRequirement{Declared: true, All: true} +} + +// fieldReqPatterns returns a FieldRequirement with the supplied patterns. +func fieldReqPatterns(patterns ...typesv1.PatternObject) typesv1.FieldRequirement { + return typesv1.FieldRequirement{Declared: true, Patterns: patterns} +} + +func exactPattern(path string) typesv1.PatternObject { + return typesv1.PatternObject{Exact: path} +} + +func prefixPattern(path string) typesv1.PatternObject { + return typesv1.PatternObject{Prefix: path} +} + +func suffixPattern(path string) typesv1.PatternObject { + return typesv1.PatternObject{Suffix: path} +} + +func containsPattern(s string) typesv1.PatternObject { + return typesv1.PatternObject{Contains: s} +} + +// TestCompileSpec_Empty verifies that an empty rule list produces a spec where +// all FieldSpec.InUse fields are false. +func TestCompileSpec_Empty(t *testing.T) { + spec := CompileSpec(nil) + + fields := []objectcache.FieldSpec{ + spec.Opens, spec.Execs, spec.Capabilities, spec.Syscalls, + spec.Endpoints, spec.EgressDomains, spec.EgressAddresses, + spec.IngressDomains, spec.IngressAddresses, + } + for i, f := range fields { + assert.False(t, f.InUse, "field %d should not be in use when no rules provided", i) + assert.False(t, f.All, "field %d All should be false when no rules provided", i) + } +} + +// TestCompileSpec_NilProfileDataRequiredSkipped verifies that rules with nil +// ProfileDataRequired do not contribute to the spec. +func TestCompileSpec_NilProfileDataRequiredSkipped(t *testing.T) { + rules := []typesv1.Rule{ + {ID: "no-pdr", ProfileDataRequired: nil}, + {ID: "also-no-pdr", ProfileDataRequired: nil}, + } + spec := CompileSpec(rules) + + assert.False(t, spec.Opens.InUse, "opens should not be in use when all rules have nil ProfileDataRequired") + assert.False(t, spec.Execs.InUse, "execs should not be in use when all rules have nil ProfileDataRequired") +} + +// TestCompileSpec_DeterministicHash verifies that the same rules compiled twice +// produce the same hash, and that rule ordering does not change the hash. +func TestCompileSpec_DeterministicHash(t *testing.T) { + pdr := &typesv1.ProfileDataRequired{ + Opens: fieldReqPatterns(exactPattern("/bin/sh"), prefixPattern("/usr/")), + Execs: fieldReqAll(), + } + rule := makeRule(pdr) + + spec1 := CompileSpec([]typesv1.Rule{rule}) + spec2 := CompileSpec([]typesv1.Rule{rule}) + assert.Equal(t, spec1.Hash, spec2.Hash, "same rules should always produce the same hash") + assert.NotEmpty(t, spec1.Hash, "hash should not be empty") + + // Order of rules should not change the hash. + pdr2 := &typesv1.ProfileDataRequired{ + Execs: fieldReqAll(), + } + rule2 := typesv1.Rule{ID: "r2", ProfileDataRequired: pdr2} + + specAB := CompileSpec([]typesv1.Rule{rule, rule2}) + specBA := CompileSpec([]typesv1.Rule{rule2, rule}) + assert.Equal(t, specAB.Hash, specBA.Hash, "rule order should not affect hash") +} + +// TestCompileSpec_AllPoisonsField verifies that a single rule with Opens.All=true +// makes spec.Opens.All=true regardless of other rules with exact patterns. +func TestCompileSpec_AllPoisonsField(t *testing.T) { + pdrExact := &typesv1.ProfileDataRequired{ + Opens: fieldReqPatterns(exactPattern("/bin/sh")), + } + pdrAll := &typesv1.ProfileDataRequired{ + Opens: fieldReqAll(), + } + + rules := []typesv1.Rule{ + makeRule(pdrExact), + makeRule(pdrAll), + } + spec := CompileSpec(rules) + + assert.True(t, spec.Opens.All, "All=true should take precedence over exact patterns") + assert.True(t, spec.Opens.InUse, "field should be in use") +} + +// TestCompileSpec_UnionAcrossRules verifies that patterns from multiple rules are +// unioned: both exact and prefix patterns from different rules appear in the spec. +func TestCompileSpec_UnionAcrossRules(t *testing.T) { + rule1 := makeRule(&typesv1.ProfileDataRequired{ + Opens: fieldReqPatterns(exactPattern("/bin/sh")), + }) + rule2 := makeRule(&typesv1.ProfileDataRequired{ + Opens: fieldReqPatterns(prefixPattern("/usr/")), + }) + + spec := CompileSpec([]typesv1.Rule{rule1, rule2}) + + require.NotNil(t, spec.Opens.Exact, "exact map should not be nil") + _, hasExact := spec.Opens.Exact["/bin/sh"] + assert.True(t, hasExact, "exact /bin/sh should be present after union") + assert.Contains(t, spec.Opens.Prefixes, "/usr/", "prefix /usr/ should be present after union") + assert.True(t, spec.Opens.InUse) +} + +// TestCompileSpec_BuildsMatchers verifies that a spec with prefixes has a +// non-nil PrefixMatcher that correctly matches. +func TestCompileSpec_BuildsMatchers(t *testing.T) { + rule := makeRule(&typesv1.ProfileDataRequired{ + Opens: fieldReqPatterns(prefixPattern("/bin/")), + }) + spec := CompileSpec([]typesv1.Rule{rule}) + + require.NotNil(t, spec.Opens.PrefixMatcher, "PrefixMatcher should be built from prefix patterns") + assert.True(t, spec.Opens.PrefixMatcher.HasMatch("/bin/sh"), "PrefixMatcher should match /bin/sh") + assert.False(t, spec.Opens.PrefixMatcher.HasMatch("/etc/passwd"), "PrefixMatcher should not match /etc/passwd") +} + +// TestCompileSpec_SuffixMatcher verifies that a spec with suffixes has a +// non-nil SuffixMatcher that correctly matches. +func TestCompileSpec_SuffixMatcher(t *testing.T) { + rule := makeRule(&typesv1.ProfileDataRequired{ + Opens: fieldReqPatterns(suffixPattern(".conf")), + }) + spec := CompileSpec([]typesv1.Rule{rule}) + + require.NotNil(t, spec.Opens.SuffixMatcher, "SuffixMatcher should be built from suffix patterns") + assert.True(t, spec.Opens.SuffixMatcher.HasMatch("/etc/app.conf"), "SuffixMatcher should match /etc/app.conf") + assert.False(t, spec.Opens.SuffixMatcher.HasMatch("/etc/passwd"), "SuffixMatcher should not match /etc/passwd") +} + +// TestCompileSpec_DeduplicatesPatterns verifies that duplicate patterns from +// multiple rules appear only once in the final spec. +func TestCompileSpec_DeduplicatesPatterns(t *testing.T) { + rule1 := makeRule(&typesv1.ProfileDataRequired{ + Opens: fieldReqPatterns(prefixPattern("/bin/")), + }) + rule2 := makeRule(&typesv1.ProfileDataRequired{ + Opens: fieldReqPatterns(prefixPattern("/bin/")), + }) + + spec := CompileSpec([]typesv1.Rule{rule1, rule2}) + + count := 0 + for _, p := range spec.Opens.Prefixes { + if p == "/bin/" { + count++ + } + } + assert.Equal(t, 1, count, "duplicate prefix /bin/ should appear only once after dedup") +} + +// TestCompileSpec_MultipleSurfaces verifies that multiple surfaces from a single +// rule are independently compiled. +func TestCompileSpec_MultipleSurfaces(t *testing.T) { + rule := makeRule(&typesv1.ProfileDataRequired{ + Opens: fieldReqPatterns(exactPattern("/bin/sh")), + Execs: fieldReqAll(), + Syscalls: fieldReqPatterns(containsPattern("read")), + }) + spec := CompileSpec([]typesv1.Rule{rule}) + + assert.True(t, spec.Opens.InUse) + assert.False(t, spec.Opens.All) + assert.True(t, spec.Execs.InUse) + assert.True(t, spec.Execs.All) + assert.True(t, spec.Syscalls.InUse) + assert.Contains(t, spec.Syscalls.Contains, "read") +} diff --git a/pkg/objectcache/containerprofilecache/projection_trie.go b/pkg/objectcache/containerprofilecache/projection_trie.go new file mode 100644 index 0000000000..e4c3a793ef --- /dev/null +++ b/pkg/objectcache/containerprofilecache/projection_trie.go @@ -0,0 +1,89 @@ +package containerprofilecache + +import "strings" + +// trie implements a simple byte-level prefix trie for O(n) prefix matching +// where n is the length of the query string. Used by FieldSpec for prefix and +// suffix (reversed-insertion) matching. +type trie struct { + children map[rune]*trie + terminal bool // true if this node marks the end of an inserted pattern +} + +func newTrie(patterns []string) *trie { + root := &trie{} + for _, p := range patterns { + root.insert(p) + } + return root +} + +func (t *trie) insert(pattern string) { + cur := t + for _, ch := range pattern { + if cur.children == nil { + cur.children = make(map[rune]*trie) + } + next, ok := cur.children[ch] + if !ok { + next = &trie{} + cur.children[ch] = next + } + cur = next + } + cur.terminal = true +} + +// HasMatch reports whether any inserted pattern is a prefix of s. +func (t *trie) HasMatch(s string) bool { + cur := t + if cur.terminal { + return true // empty pattern matches everything + } + for _, ch := range s { + next, ok := cur.children[ch] + if !ok { + return false + } + cur = next + if cur.terminal { + return true + } + } + return false +} + +// HasMatchSuffix reports whether any inserted pattern is a suffix of s. +// The trie must have been built with reversed patterns (via newSuffixTrie). +func (t *trie) HasMatchSuffix(s string) bool { + return t.HasMatch(reverseString(s)) +} + +// newSuffixTrie builds a trie from reversed patterns so that HasMatchSuffix +// can perform suffix matching via forward traversal of the reversed query. +func newSuffixTrie(patterns []string) *trie { + reversed := make([]string, len(patterns)) + for i, p := range patterns { + reversed[i] = reverseString(p) + } + return newTrie(reversed) +} + +func reverseString(s string) string { + runes := []rune(s) + for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { + runes[i], runes[j] = runes[j], runes[i] + } + return string(runes) +} + +// containsMatch reports whether any pattern in the list is a substring of s. +// Linear scan; used only for Contains patterns (expected to be short lists). +func containsMatch(patterns []string, s string) bool { + for _, p := range patterns { + if strings.Contains(s, p) { + return true + } + } + return false +} diff --git a/pkg/objectcache/containerprofilecache/projection_trie_test.go b/pkg/objectcache/containerprofilecache/projection_trie_test.go new file mode 100644 index 0000000000..e48fda5dd4 --- /dev/null +++ b/pkg/objectcache/containerprofilecache/projection_trie_test.go @@ -0,0 +1,82 @@ +package containerprofilecache + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTrie_PrefixMatch(t *testing.T) { + tr := newTrie([]string{"/bin/", "/usr/"}) + + assert.True(t, tr.HasMatch("/bin/sh"), "expected /bin/sh to match prefix /bin/") + assert.True(t, tr.HasMatch("/usr/local/bin/curl"), "expected /usr/local/bin/curl to match prefix /usr/") + assert.False(t, tr.HasMatch("/etc/passwd"), "expected /etc/passwd not to match any prefix") + assert.False(t, tr.HasMatch("/bi"), "expected /bi (shorter than pattern) not to match") +} + +func TestTrie_EmptyPatternMatchesAll(t *testing.T) { + tr := newTrie([]string{""}) + + assert.True(t, tr.HasMatch("anything"), "empty pattern should match any string") + assert.True(t, tr.HasMatch(""), "empty pattern should match empty string") + assert.True(t, tr.HasMatch("/etc/passwd"), "empty pattern should match /etc/passwd") +} + +func TestTrie_SuffixMatch(t *testing.T) { + tr := newSuffixTrie([]string{".log"}) + + assert.True(t, tr.HasMatchSuffix("/var/log/app.log"), "expected .log suffix match") + assert.True(t, tr.HasMatchSuffix("app.log"), "expected bare .log suffix match") + assert.False(t, tr.HasMatchSuffix("/etc/passwd"), "expected no suffix match for /etc/passwd") + assert.False(t, tr.HasMatchSuffix("/var/log"), "expected /var/log not to match .log suffix") +} + +func TestTrie_SuffixMatch_MultipleSuffixes(t *testing.T) { + tr := newSuffixTrie([]string{".log", ".conf"}) + + assert.True(t, tr.HasMatchSuffix("/etc/app.conf"), "expected .conf suffix match") + assert.True(t, tr.HasMatchSuffix("/var/log/app.log"), "expected .log suffix match") + assert.False(t, tr.HasMatchSuffix("/etc/passwd"), "expected no match for /etc/passwd") +} + +func TestContainsMatch(t *testing.T) { + assert.True(t, containsMatch([]string{"http"}, "is_http_request"), "http should be a substring of is_http_request") + assert.True(t, containsMatch([]string{"xyz", "http"}, "is_http_request"), "should match when any pattern is found") + assert.False(t, containsMatch([]string{"xyz"}, "hello"), "xyz is not a substring of hello") + assert.False(t, containsMatch([]string{}, "hello"), "empty patterns should not match") + assert.False(t, containsMatch([]string{"abc"}, ""), "no pattern should match empty string unless empty pattern") +} + +func TestTrie_PrefixMatch_ExactString(t *testing.T) { + // A pattern that exactly equals the query string should also match (prefix of itself). + tr := newTrie([]string{"/bin/sh"}) + + assert.True(t, tr.HasMatch("/bin/sh"), "pattern equal to query should match") + // /bin/sh IS a prefix of /bin/sh/extra, so this should also match. + assert.True(t, tr.HasMatch("/bin/sh/extra"), "/bin/sh is a prefix of /bin/sh/extra, so it should match") + + // A string shorter than the pattern should not match. + tr2 := newTrie([]string{"/bin/"}) + assert.False(t, tr2.HasMatch("/bi"), "shorter string with no terminal should not match") +} + +func TestTrie_MultiplePatterns(t *testing.T) { + tr := newTrie([]string{"/bin/", "/etc/", "/usr/"}) + + assert.True(t, tr.HasMatch("/bin/bash")) + assert.True(t, tr.HasMatch("/etc/passwd")) + assert.True(t, tr.HasMatch("/usr/bin/python")) + assert.False(t, tr.HasMatch("/var/log/syslog")) + assert.False(t, tr.HasMatch("/proc/1/maps")) +} + +func TestTrie_UnicodePatterns(t *testing.T) { + // DynamicIdentifier is U+22EF "⋯". Verify the trie handles multi-byte runes correctly. + pattern := "/data/⋯/config" + tr := newTrie([]string{pattern}) + + assert.True(t, tr.HasMatch(pattern), "exact unicode pattern should match itself as prefix") + assert.True(t, tr.HasMatch(pattern+"/extra"), "pattern should match longer strings with unicode") + assert.False(t, tr.HasMatch("/data/x/config"), "different segment should not match") +} diff --git a/pkg/objectcache/containerprofilecache/reconciler.go b/pkg/objectcache/containerprofilecache/reconciler.go index 29c0307af3..14be22eaa7 100644 --- a/pkg/objectcache/containerprofilecache/reconciler.go +++ b/pkg/objectcache/containerprofilecache/reconciler.go @@ -48,7 +48,28 @@ func (c *ContainerProfileCacheImpl) tickLoop(ctx context.Context) { case <-ctx.Done(): logger.L().Info("ContainerProfileCache reconciler stopped") return + case <-c.nudge: + // Spec changed — re-project all entries immediately without + // waiting for the next periodic tick. Use trailing-edge consolidation: + // mark pending so that if a refresh is already running it will + // re-run once after it finishes, preventing entries from staying on + // an old spec for up to one full reconcile interval. + if c.cfg.ProfileProjection.DetailedMetricsEnabled { + c.metricsManager.IncProjectionReconcileTriggered("nudge") + } + c.refreshPending.Store(true) + if c.refreshInProgress.CompareAndSwap(false, true) { + go func() { + defer c.refreshInProgress.Store(false) + for c.refreshPending.Swap(false) { + c.refreshAllEntries(ctx) + } + }() + } case <-ticker.C: + if c.cfg.ProfileProjection.DetailedMetricsEnabled { + c.metricsManager.IncProjectionReconcileTriggered("tick") + } start := time.Now() entriesBefore := c.entries.Len() pendingBefore := c.pending.Len() @@ -224,6 +245,21 @@ func (c *ContainerProfileCacheImpl) refreshAllEntries(ctx context.Context) { c.refreshOneEntry(ctx, w.id, w.e) }) } + + c.currentSpecMu.RLock() + var currentHash string + if c.currentSpec != nil { + currentHash = c.currentSpec.Hash + } + c.currentSpecMu.RUnlock() + var stale float64 + c.entries.Range(func(_ string, e *CachedContainerProfile) bool { + if e.SpecHash != currentHash { + stale++ + } + return true + }) + c.metricsManager.SetProjectionStaleEntries(stale) } // refreshOneEntry refreshes a single cache entry under the per-container lock. @@ -361,12 +397,19 @@ func (c *ContainerProfileCacheImpl) refreshOneEntry(ctx context.Context, id stri // Fast-skip when nothing changed. We match "absent" (nil) with empty RV: // this avoids spurious rebuilds when an optional source is still missing, - // as long as it was also missing at the last build. + // as long as it was also missing at the last build. Also skip when the + // projection spec hash matches: if neither the data nor the spec changed, + // the projected output would be identical. + currentSpecHash := "" + if spec := c.snapshotSpec(); spec != nil { + currentSpecHash = spec.Hash + } if rvsMatchCP(cp, e.RV) && rvsMatchAP(userManagedAP, e.UserManagedAPRV) && rvsMatchNN(userManagedNN, e.UserManagedNNRV) && rvsMatchAP(userAP, e.UserAPRV) && - rvsMatchNN(userNN, e.UserNNRV) { + rvsMatchNN(userNN, e.UserNNRV) && + e.SpecHash == currentSpecHash { return } @@ -452,9 +495,6 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( c.emitOverlayMetrics(userManagedAP, userManagedNN, warnings) } // Ladder pass #2: label-referenced user overlay AP + NN. - shared := userAP == nil && userNN == nil && - userManagedAP == nil && userManagedNN == nil && - cp != nil var userWarnings []partialProfileWarning if userAP != nil || userNN != nil { p, w := projectUserProfiles(projected, userAP, userNN, pod, prev.ContainerName) @@ -469,9 +509,19 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( tree.AddCallStack(stack) } + // Project under the current spec. + spec := c.snapshotSpec() + applyStart := time.Now() + projectedCP := Apply(spec, projected, tree) + if c.cfg.ProfileProjection.DetailedMetricsEnabled { + c.metricsManager.ObserveProjectionApplyDuration(time.Since(applyStart)) + c.observeMemoryMetrics(projected, projectedCP) + } + newEntry := &CachedContainerProfile{ - Profile: projected, - State: &objectcache.ProfileState{Completion: effectiveCP.Annotations[helpersv1.CompletionMetadataKey], Status: effectiveCP.Annotations[helpersv1.StatusMetadataKey], Name: effectiveCP.Name}, + Projected: projectedCP, + SpecHash: projectedCP.SpecHash, + State: &objectcache.ProfileState{Completion: effectiveCP.Annotations[helpersv1.CompletionMetadataKey], Status: effectiveCP.Annotations[helpersv1.StatusMetadataKey], Name: effectiveCP.Name}, CallStackTree: tree, ContainerName: prev.ContainerName, PodName: prev.PodName, @@ -480,7 +530,6 @@ func (c *ContainerProfileCacheImpl) rebuildEntryFromSources( WorkloadID: prev.WorkloadID, CPName: prev.CPName, WorkloadName: prev.WorkloadName, - Shared: shared, RV: rvOfCP(cp), UserManagedAPRV: rvOfAP(userManagedAP), UserManagedNNRV: rvOfNN(userManagedNN), @@ -525,6 +574,50 @@ func rvOfNN(o *v1beta1.NetworkNeighborhood) string { return o.ResourceVersion } +// observeMemoryMetrics records per-field entry counts, retention ratios, and +// total byte sizes for the raw vs projected profile. Called only when +// DetailedMetricsEnabled is true. +func (c *ContainerProfileCacheImpl) observeMemoryMetrics(raw *v1beta1.ContainerProfile, pcp *objectcache.ProjectedContainerProfile) { + type pair struct { + name string + raw []string + proj objectcache.ProjectedField + } + pairs := []pair{ + {"opens", extractOpensPaths(raw), pcp.Opens}, + {"execs", extractExecsPaths(raw), pcp.Execs}, + {"endpoints", extractEndpointPaths(raw), pcp.Endpoints}, + {"capabilities", raw.Spec.Capabilities, pcp.Capabilities}, + {"syscalls", raw.Spec.Syscalls, pcp.Syscalls}, + {"egress_domains", extractEgressDomains(raw), pcp.EgressDomains}, + {"egress_addresses", extractEgressAddresses(raw), pcp.EgressAddresses}, + {"ingress_domains", extractIngressDomains(raw), pcp.IngressDomains}, + {"ingress_addresses", extractIngressAddresses(raw), pcp.IngressAddresses}, + } + + var rawBytes, projBytes float64 + for _, p := range pairs { + rawCount := float64(len(p.raw)) + retainedCount := float64(len(p.proj.Values) + len(p.proj.Patterns)) + for _, s := range p.raw { + rawBytes += float64(len(s)) + } + for s := range p.proj.Values { + projBytes += float64(len(s)) + } + for _, s := range p.proj.Patterns { + projBytes += float64(len(s)) + } + c.metricsManager.ObserveProfileEntriesRaw(p.name, rawCount) + c.metricsManager.ObserveProfileEntriesRetained(p.name, retainedCount) + if rawCount > 0 { + c.metricsManager.ObserveProfileRetentionRatio(p.name, retainedCount/rawCount) + } + } + c.metricsManager.ObserveProfileRawSize(rawBytes) + c.metricsManager.ObserveProfileProjectedSize(projBytes) +} + // retryPendingEntries re-issues GetContainerProfile for every containerID that // was seen on ContainerCallback(Add) but whose CP was not yet in storage. On // success the entry is promoted into the main cache and removed from pending. diff --git a/pkg/objectcache/containerprofilecache/reconciler_test.go b/pkg/objectcache/containerprofilecache/reconciler_test.go index 0bdf92f180..3b572dc9c0 100644 --- a/pkg/objectcache/containerprofilecache/reconciler_test.go +++ b/pkg/objectcache/containerprofilecache/reconciler_test.go @@ -150,7 +150,7 @@ func newReconcilerCache(t *testing.T, client storage.ProfileClient, k8s objectca // addContainer (which requires priming shared data + instance-id machinery). func newEntry(cp *v1beta1.ContainerProfile, containerName, podName, namespace, podUID string) *CachedContainerProfile { return &CachedContainerProfile{ - Profile: cp, + Projected: Apply(nil, cp, nil), State: &objectcache.ProfileState{Name: cp.Name}, ContainerName: containerName, PodName: podName, @@ -158,7 +158,6 @@ func newEntry(cp *v1beta1.ContainerProfile, containerName, podName, namespace, p PodUID: podUID, CPName: cp.Name, RV: cp.ResourceVersion, - Shared: true, } } @@ -178,7 +177,7 @@ func TestReconcilerKeepsEntryWhenPodMissing(t *testing.T) { c.reconcileOnce(context.Background()) - assert.NotNil(t, c.GetContainerProfile(id), "entry must be retained when pod is missing from cache") + assert.NotNil(t, c.GetProjectedContainerProfile(id), "entry must be retained when pod is missing from cache") assert.Equal(t, 0, metrics.eviction("pod_stopped"), "no eviction when pod is absent") } @@ -203,7 +202,7 @@ func TestReconcilerEvictsTerminatedContainer(t *testing.T) { c.reconcileOnce(context.Background()) - assert.Nil(t, c.GetContainerProfile(id), "terminated container entry must be evicted") + assert.Nil(t, c.GetProjectedContainerProfile(id), "terminated container entry must be evicted") assert.Equal(t, 1, metrics.eviction("pod_stopped"), "should report one eviction") } @@ -229,7 +228,7 @@ func TestReconcilerKeepsWaitingContainer(t *testing.T) { c.reconcileOnce(context.Background()) - assert.NotNil(t, c.GetContainerProfile(id), "waiting container entry must be retained") + assert.NotNil(t, c.GetProjectedContainerProfile(id), "waiting container entry must be retained") assert.Equal(t, 0, metrics.eviction("pod_stopped"), "no eviction for Waiting state") } @@ -254,7 +253,7 @@ func TestReconcilerKeepsRunningContainer(t *testing.T) { c.reconcileOnce(context.Background()) - assert.NotNil(t, c.GetContainerProfile(id), "running container entry must remain") + assert.NotNil(t, c.GetProjectedContainerProfile(id), "running container entry must remain") assert.Equal(t, 0, metrics.eviction("pod_stopped"), "should not evict a running entry") } @@ -355,7 +354,7 @@ func TestRefreshFastSkipWhenAllRVsMatch(t *testing.T) { id := "c1" entry := &CachedContainerProfile{ - Profile: cp, + Projected: Apply(nil, cp, nil), State: &objectcache.ProfileState{Name: cp.Name}, ContainerName: "nginx", PodName: "nginx-abc", @@ -364,13 +363,11 @@ func TestRefreshFastSkipWhenAllRVsMatch(t *testing.T) { CPName: "cp", UserAPRef: &namespacedName{Namespace: "default", Name: "override"}, UserNNRef: &namespacedName{Namespace: "default", Name: "override"}, - Shared: false, RV: "100", UserAPRV: "50", UserNNRV: "60", } c.entries.Set(id, entry) - beforeProfilePtr := entry.Profile c.refreshAllEntries(context.Background()) @@ -383,7 +380,6 @@ func TestRefreshFastSkipWhenAllRVsMatch(t *testing.T) { require.True(t, ok) // Same pointer: the entry was NOT rebuilt. assert.Same(t, entry, stored, "entry must not be replaced on fast-skip") - assert.Same(t, beforeProfilePtr, stored.Profile, "Profile pointer must not change on fast-skip") // No legacy-load metric emitted on fast-skip. assert.Equal(t, 0, metrics.legacyLoad(kindApplication, completenessFull)) assert.Equal(t, 0, metrics.legacyLoad(kindNetwork, completenessFull)) @@ -412,7 +408,7 @@ func TestRefreshRebuildsOnUserAPChange(t *testing.T) { id := "c1" entry := &CachedContainerProfile{ - Profile: cp, + Projected: Apply(nil, cp, nil), State: &objectcache.ProfileState{Name: cp.Name}, ContainerName: "nginx", PodName: "nginx-abc", @@ -420,19 +416,26 @@ func TestRefreshRebuildsOnUserAPChange(t *testing.T) { PodUID: "uid-1", CPName: "cp", UserAPRef: &namespacedName{Namespace: "default", Name: "override"}, - Shared: false, RV: "100", UserAPRV: "50", // stale: storage now returns 51 } c.entries.Set(id, entry) + c.SetProjectionSpec(objectcache.RuleProjectionSpec{ + Capabilities: objectcache.FieldSpec{InUse: true, All: true}, + Hash: "test-caps", + }) c.refreshAllEntries(context.Background()) stored, ok := c.entries.Load(id) require.True(t, ok) assert.NotSame(t, entry, stored, "entry must be replaced when user-AP RV changes") assert.Equal(t, "51", stored.UserAPRV, "new UserAPRV must be recorded") - assert.ElementsMatch(t, []string{"SYS_PTRACE", "NET_BIND_SERVICE"}, stored.Profile.Spec.Capabilities, + caps := make([]string, 0, len(stored.Projected.Capabilities.Values)) + for cap := range stored.Projected.Capabilities.Values { + caps = append(caps, cap) + } + assert.ElementsMatch(t, []string{"SYS_PTRACE", "NET_BIND_SERVICE"}, caps, "rebuilt projection must include merged overlay capabilities") } @@ -459,7 +462,6 @@ func TestRefreshRebuildsOnCPChange(t *testing.T) { stored, ok := c.entries.Load(id) require.True(t, ok) assert.Equal(t, "101", stored.RV, "RV must update to the fresh CP's version") - assert.Same(t, cp, stored.Profile, "shared fast-path: fresh CP pointer stored directly") } // TestT8_EndToEndRefreshUpdatesProjection — delta #5. Mutate the user-AP in @@ -486,12 +488,9 @@ func TestT8_EndToEndRefreshUpdatesProjection(t *testing.T) { metrics := newCountingMetrics() c := newReconcilerCache(t, client, k8s, metrics) - // Initial entry built from base CP + overlay: use addContainer's private - // buildEntry logic via projectUserProfiles directly, then seed. - initialProjected, _ := projectUserProfiles(cp, ap, nil, nil, "nginx") id := "c1" entry := &CachedContainerProfile{ - Profile: initialProjected, + Projected: Apply(nil, cp, nil), State: &objectcache.ProfileState{Name: cp.Name}, ContainerName: "nginx", PodName: "nginx-abc", @@ -499,7 +498,6 @@ func TestT8_EndToEndRefreshUpdatesProjection(t *testing.T) { PodUID: "uid-1", CPName: "cp", UserAPRef: &namespacedName{Namespace: "default", Name: "override"}, - Shared: false, RV: "100", UserAPRV: "50", } @@ -516,6 +514,10 @@ func TestT8_EndToEndRefreshUpdatesProjection(t *testing.T) { }, } + c.SetProjectionSpec(objectcache.RuleProjectionSpec{ + Execs: objectcache.FieldSpec{InUse: true, All: true}, + Hash: "test-execs", + }) c.refreshAllEntries(context.Background()) stored, ok := c.entries.Load(id) @@ -524,8 +526,8 @@ func TestT8_EndToEndRefreshUpdatesProjection(t *testing.T) { // The projection must include the new exec (merged on top of the base CP's exec). var paths []string - for _, e := range stored.Profile.Spec.Execs { - paths = append(paths, e.Path) + for path := range stored.Projected.Execs.Values { + paths = append(paths, path) } assert.Contains(t, paths, "/bin/base", "base CP exec must be preserved") assert.Contains(t, paths, "/bin/new", "new user-AP exec must be projected into the cache") @@ -632,7 +634,7 @@ func TestRefreshPreservesEntryOnTransientOverlayError(t *testing.T) { id := "c1" entry := &CachedContainerProfile{ - Profile: cp, + Projected: Apply(nil, cp, nil), State: &objectcache.ProfileState{Name: cp.Name}, ContainerName: "nginx", PodName: "nginx-abc", @@ -647,7 +649,6 @@ func TestRefreshPreservesEntryOnTransientOverlayError(t *testing.T) { UserAPRV: tc.overlay.userAPRV, UserNNRef: tc.overlay.userNNRef, UserNNRV: tc.overlay.userNNRV, - Shared: false, } c.entries.Set(id, entry) @@ -774,7 +775,7 @@ func TestRefreshHonorsContextCancellationMidRPC(t *testing.T) { } cache := NewContainerProfileCache(cfg, blocking, k8s, nil) cache.SeedEntryForTest("id1", &CachedContainerProfile{ - Profile: cp, + Projected: Apply(nil, cp, nil), State: &objectcache.ProfileState{Name: cp.Name}, ContainerName: "c1", PodName: "pod1", @@ -861,7 +862,7 @@ func TestRetryPendingEntries_CPCreatedAfterAdd(t *testing.T) { // addContainer: sees 404 -> pending bookkeeping, not an entry. require.NoError(t, c.addContainer(eventContainer(id), context.Background())) - assert.Nil(t, c.GetContainerProfile(id), "no entry before CP exists in storage") + assert.Nil(t, c.GetProjectedContainerProfile(id), "no entry before CP exists in storage") assert.Equal(t, 1, c.pending.Len(), "container recorded as pending") // Storage creates the CP asynchronously (60s after start in real runs). @@ -872,7 +873,7 @@ func TestRetryPendingEntries_CPCreatedAfterAdd(t *testing.T) { // promotes on successful GET. c.retryPendingEntries(context.Background()) - assert.NotNil(t, c.GetContainerProfile(id), "entry promoted after CP appears") + assert.NotNil(t, c.GetProjectedContainerProfile(id), "entry promoted after CP appears") assert.Equal(t, 0, c.pending.Len(), "pending drained on successful promotion") // Exactly two GETs: one from addContainer (404), one from retry (200). assert.Equal(t, 2, client.getCPCalls, "retry should only re-GET once per tick") @@ -942,7 +943,7 @@ func TestPartialCP_NonPreRunning_StaysPending(t *testing.T) { // fresh container start observed by a running agent. require.NoError(t, c.addContainer(eventContainer(id), context.Background())) - assert.Nil(t, c.GetContainerProfile(id), "partial CP must not populate cache on fresh container") + assert.Nil(t, c.GetProjectedContainerProfile(id), "partial CP must not populate cache on fresh container") assert.Equal(t, 1, c.pending.Len(), "partial-on-restart stays pending") // Simulate the CP becoming Full (new agent-side aggregation round). @@ -950,7 +951,7 @@ func TestPartialCP_NonPreRunning_StaysPending(t *testing.T) { cp.ResourceVersion = "2" c.retryPendingEntries(context.Background()) - assert.NotNil(t, c.GetContainerProfile(id), "Full CP promotes pending entry") + assert.NotNil(t, c.GetProjectedContainerProfile(id), "Full CP promotes pending entry") assert.Equal(t, 0, c.pending.Len(), "pending drained on Full") } @@ -978,7 +979,7 @@ func TestPartialCP_PreRunning_Accepted(t *testing.T) { primePreRunningSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") require.NoError(t, c.addContainer(eventContainer(id), context.Background())) - assert.NotNil(t, c.GetContainerProfile(id), "partial CP accepted for PreRunning container") + assert.NotNil(t, c.GetProjectedContainerProfile(id), "partial CP accepted for PreRunning container") assert.Equal(t, 0, c.pending.Len(), "not pending when accepted") } @@ -1025,20 +1026,20 @@ func TestRefreshDoesNotResurrectDeletedEntry(t *testing.T) { id := "container-resurrect" primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") require.NoError(t, c.addContainer(eventContainer(id), context.Background())) - require.NotNil(t, c.GetContainerProfile(id)) + require.NotNil(t, c.GetProjectedContainerProfile(id)) // Simulate the race: snapshot the entry, delete, then call refreshOneEntry. entry, ok := c.entries.Load(id) require.True(t, ok) c.deleteContainer(id) - require.Nil(t, c.GetContainerProfile(id), "entry gone after delete") + require.Nil(t, c.GetProjectedContainerProfile(id), "entry gone after delete") // Refresh for the deleted id must bail instead of resurrecting. c.containerLocks.WithLock(id, func() { c.refreshOneEntry(context.Background(), id, entry) }) - assert.Nil(t, c.GetContainerProfile(id), "refresh must not resurrect deleted entry") + assert.Nil(t, c.GetProjectedContainerProfile(id), "refresh must not resurrect deleted entry") } // TestUserDefinedProfileOnly_NoBaseCP verifies that a container with only a @@ -1057,6 +1058,12 @@ func TestUserDefinedProfileOnly_NoBaseCP(t *testing.T) { client := &fakeProfileClient{cp: nil, cpErr: assertErrNotFound("no-base"), ap: userAP} c, k8s := newTestCache(t, client) + c.SetProjectionSpec(objectcache.RuleProjectionSpec{ + Capabilities: objectcache.FieldSpec{InUse: true, All: true}, + Execs: objectcache.FieldSpec{InUse: true, All: true}, + Hash: "user-only-test", + }) + id := "container-user-only" primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") ct := eventContainer(id) @@ -1064,10 +1071,11 @@ func TestUserDefinedProfileOnly_NoBaseCP(t *testing.T) { require.NoError(t, c.addContainer(ct, context.Background())) - cached := c.GetContainerProfile(id) + cached := c.GetProjectedContainerProfile(id) require.NotNil(t, cached, "entry populated from user-AP even without base CP") // The synthesized CP + projection should carry the user AP's capabilities. - assert.Contains(t, cached.Spec.Capabilities, "CAP_NET_ADMIN") + _, hasCap := cached.Capabilities.Values["CAP_NET_ADMIN"] + assert.True(t, hasCap, "projected entry must contain CAP_NET_ADMIN from user-AP") } // primePreRunningSharedData is a variant of primeSharedData that sets the @@ -1178,18 +1186,21 @@ func TestUserManagedProfileMerged(t *testing.T) { } c, k8s := newTestCache(t, client) + c.SetProjectionSpec(objectcache.RuleProjectionSpec{ + Execs: objectcache.FieldSpec{InUse: true, All: true}, + Hash: "user-managed-test", + }) + id := "container-user-managed" primeSharedData(t, k8s, id, "wlid://cluster-a/namespace-default/deployment-nginx") require.NoError(t, c.addContainer(eventContainer(id), context.Background())) - cached := c.GetContainerProfile(id) + cached := c.GetProjectedContainerProfile(id) require.NotNil(t, cached, "entry populated") - var paths []string - for _, e := range cached.Spec.Execs { - paths = append(paths, e.Path) - } - assert.Contains(t, paths, "/bin/X", "base workload AP exec must be present") - assert.Contains(t, paths, "/bin/Y", "user-managed (ug-) AP exec must be merged in") + _, hasX := cached.Execs.Values["/bin/X"] + _, hasY := cached.Execs.Values["/bin/Y"] + assert.True(t, hasX, "base workload AP exec must be present") + assert.True(t, hasY, "user-managed (ug-) AP exec must be merged in") // Verify the RV was captured so a later user-managed update would trigger // a refresh rebuild. @@ -1197,3 +1208,47 @@ func TestUserManagedProfileMerged(t *testing.T) { require.True(t, ok) assert.Equal(t, "9", entry.UserManagedAPRV, "UserManagedAPRV recorded at add time") } + +// TestSpecChange_TriggersReprojection — T5 nudge integration. +// +// After SetProjectionSpec is called with a new spec, RefreshAllEntriesForTest +// re-projects existing entries under the new spec. Without the nudge mechanism +// tests cannot wait for the background goroutine, so we drive it explicitly. +func TestSpecChange_TriggersReprojection(t *testing.T) { + cp := &v1beta1.ContainerProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "cp", Namespace: "default", ResourceVersion: "1"}, + Spec: v1beta1.ContainerProfileSpec{ + Capabilities: []string{"SYS_PTRACE", "NET_ADMIN"}, + }, + } + client := &countingProfileClient{cp: cp} + k8s := newControllableK8sCache() + metrics := newCountingMetrics() + c := newReconcilerCache(t, client, k8s, metrics) + + id := "c-reproj" + // Seed with nil spec — InUse=false means pass-through: all entries retained. + entry := newEntry(cp, "nginx", "nginx-abc", "default", "uid-1") + c.entries.Set(id, entry) + + before := c.GetProjectedContainerProfile(id) + require.NotNil(t, before) + assert.Empty(t, before.SpecHash, "nil spec → SpecHash is empty") + assert.Contains(t, before.Capabilities.Values, "SYS_PTRACE", "nil spec → pass-through, capabilities retained") + assert.Contains(t, before.Capabilities.Values, "NET_ADMIN", "nil spec → pass-through, capabilities retained") + + // Install a spec that accepts all capabilities. + c.SetProjectionSpec(objectcache.RuleProjectionSpec{ + Capabilities: objectcache.FieldSpec{InUse: true, All: true}, + Hash: "caps-all", + }) + + // Simulate what the nudge-triggered goroutine does. + c.refreshAllEntries(context.Background()) + + after := c.GetProjectedContainerProfile(id) + require.NotNil(t, after) + assert.Equal(t, "caps-all", after.SpecHash, "after spec change → SpecHash updated, proving reprojection occurred") + assert.Contains(t, after.Capabilities.Values, "SYS_PTRACE", "after spec change → SYS_PTRACE projected") + assert.Contains(t, after.Capabilities.Values, "NET_ADMIN", "after spec change → NET_ADMIN projected") +} diff --git a/pkg/objectcache/containerprofilecache/shared_pointer_race_test.go b/pkg/objectcache/containerprofilecache/shared_pointer_race_test.go index 5fe4dffa60..0af277ba3e 100644 --- a/pkg/objectcache/containerprofilecache/shared_pointer_race_test.go +++ b/pkg/objectcache/containerprofilecache/shared_pointer_race_test.go @@ -3,24 +3,19 @@ package containerprofilecache_test // TestSharedPointerReadersDoNotCorruptCache — PR 3 Part A. // // Validates that concurrent readers and a concurrent reconciler-refresh do not -// produce data races on the shared *v1beta1.ContainerProfile pointer returned -// by GetContainerProfile. +// produce data races on the projected profile returned by +// GetProjectedContainerProfile. // // Design: // - Seed a cache entry backed by cpV1 (RV="1"). Storage serves cpV2 (RV="2") // so every RefreshAllEntriesForTest call triggers a rebuild (atomic pointer // swap on the entries map, no in-place mutation of the old slice). -// - 50 reader goroutines call GetContainerProfile in a tight loop and iterate -// the returned Spec.Execs, Spec.Opens, Spec.Capabilities slices READ-ONLY. +// - 50 reader goroutines call GetProjectedContainerProfile in a tight loop +// and read the returned projected fields READ-ONLY. // - 1 writer goroutine alternates: RefreshAllEntriesForTest (triggers rebuild) // then SeedEntryForTest (resets RV to "1" so the next refresh rebuilds again). // - Run for 500ms under -race. The race detector will surface any unprotected -// concurrent read/write pair. If none fires, the shared-pointer fast-path is -// demonstrably safe for read-only consumers. -// -// NOTE: deliberately-mutating consumer (anti-pattern) is NOT tested here because -// it is expected to trigger the race detector and would make CI non-deterministic. -// That pattern is covered by the code-review gate enforced by ReadOnlyCP (Part B). +// concurrent read/write pair. import ( "context" @@ -33,7 +28,6 @@ import ( "github.com/kubescape/node-agent/pkg/objectcache" cpc "github.com/kubescape/node-agent/pkg/objectcache/containerprofilecache" "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -84,9 +78,18 @@ func TestSharedPointerReadersDoNotCorruptCache(t *testing.T) { } cache := cpc.NewContainerProfileCache(cfg, store, k8s, nil) + // Install a spec so projected fields are non-empty. + raceSpec := objectcache.RuleProjectionSpec{ + Execs: objectcache.FieldSpec{InUse: true, All: true}, + Opens: objectcache.FieldSpec{InUse: true, All: true}, + Capabilities: objectcache.FieldSpec{InUse: true, All: true}, + Hash: "race-test", + } + cache.SetProjectionSpec(raceSpec) + seedV1 := func() { cache.SeedEntryForTest(id, &cpc.CachedContainerProfile{ - Profile: cpV1, + Projected: cpc.Apply(&raceSpec, cpV1, nil), State: &objectcache.ProfileState{Name: "cp-race"}, ContainerName: "container", PodName: "pod-race", @@ -94,7 +97,6 @@ func TestSharedPointerReadersDoNotCorruptCache(t *testing.T) { PodUID: "uid-race", CPName: "cp-race", RV: "1", // stale — guarantees refresh rebuilds on each tick - Shared: true, }) } @@ -102,35 +104,28 @@ func TestSharedPointerReadersDoNotCorruptCache(t *testing.T) { // initialization race present in goradd/maps v1.3.0 (pre-existing upstream bug). seedV1() - require.NotNil(t, cache.GetContainerProfile(id), "pre-condition: entry present before test") + require.NotNil(t, cache.GetProjectedContainerProfile(id), "pre-condition: entry present before test") ctx, cancel := context.WithTimeout(context.Background(), testDuration) defer cancel() var wg sync.WaitGroup - // 50 reader goroutines — read-only traversal of the returned profile. + // 50 reader goroutines — read-only traversal of the returned projected profile. wg.Add(numReaders) for i := 0; i < numReaders; i++ { go func() { defer wg.Done() for ctx.Err() == nil { - cp := cache.GetContainerProfile(id) - if cp == nil { + pcp := cache.GetProjectedContainerProfile(id) + if pcp == nil { runtime.Gosched() continue } - // Read-only: iterate slices without writing. - for _, e := range cp.Spec.Execs { - _ = e.Path - _ = len(e.Args) - } - for _, o := range cp.Spec.Opens { - _ = o.Path - _ = len(o.Flags) - } - _ = len(cp.Spec.Capabilities) - _ = cp.ResourceVersion + // Read-only: iterate projected values without writing. + _ = len(pcp.Execs.Values) + _ = len(pcp.Opens.Values) + _ = len(pcp.Capabilities.Values) runtime.Gosched() } }() @@ -153,32 +148,25 @@ func TestSharedPointerReadersDoNotCorruptCache(t *testing.T) { // If the race detector fired, the test is already marked as failed. We add // an explicit liveness assertion to guard against a scenario where the entry // gets permanently nil-ed out by a refresh bug. - finalCP := cache.GetContainerProfile(id) + finalPCP := cache.GetProjectedContainerProfile(id) // Entry may legitimately be nil if the last operation was a refresh that // returned cpV2 and then another seedV1 race lost; what we must NOT see is - // a panic above or a non-nil entry with a nil Profile. - if finalCP != nil { - assert.NotEmpty(t, finalCP.ResourceVersion, "final cached entry must have a non-empty RV") - } + // a panic above. + _ = finalPCP } -// TestSharedPointerFastPathPreservesPointerIdentity verifies that when the -// reconciler rebuilds an entry from a storage pointer with no overlay, the -// new entry's Profile points directly to the storage object (Shared=true, -// no DeepCopy). This is the memory property that Part A is guarding — if it -// regresses to DeepCopy-on-every-refresh the T3 memory budget is blown. -func TestSharedPointerFastPathPreservesPointerIdentity(t *testing.T) { +// TestProjectedEntryPersistsThroughRefresh verifies that after a refresh the +// projected entry is still non-nil. This replaces the old pointer-identity +// test (TestSharedPointerFastPathPreservesPointerIdentity) which relied on +// the removed Shared/Profile fields. +func TestProjectedEntryPersistsThroughRefresh(t *testing.T) { cpInStorage := &v1beta1.ContainerProfile{ ObjectMeta: metav1.ObjectMeta{ Name: "cp-identity", Namespace: "default", ResourceVersion: "99", }, - Spec: v1beta1.ContainerProfileSpec{ - Capabilities: []string{"CAP_NET_RAW"}, - }, } - store := newFakeStorage(cpInStorage) k8s := newFakeK8sCache() cfg := config.Config{ @@ -186,10 +174,8 @@ func TestSharedPointerFastPathPreservesPointerIdentity(t *testing.T) { StorageRPCBudget: 100 * time.Millisecond, } cache := cpc.NewContainerProfileCache(cfg, store, k8s, nil) - - // Seed with a stale RV so the refresh rebuilds. cache.SeedEntryForTest("id-identity", &cpc.CachedContainerProfile{ - Profile: cpInStorage, + Projected: cpc.Apply(nil, cpInStorage, nil), State: &objectcache.ProfileState{Name: "cp-identity"}, ContainerName: "container", PodName: "pod-identity", @@ -197,14 +183,8 @@ func TestSharedPointerFastPathPreservesPointerIdentity(t *testing.T) { PodUID: "uid-identity", CPName: "cp-identity", RV: "old", - Shared: true, }) - cache.RefreshAllEntriesForTest(context.Background()) - - got := cache.GetContainerProfile("id-identity") - require.NotNil(t, got, "entry must be present after refresh") - assert.Same(t, cpInStorage, got, - "shared fast-path: refresh must store the storage pointer directly (no DeepCopy)") - assert.Equal(t, "99", got.ResourceVersion, "RV must match the storage object") + pcp := cache.GetProjectedContainerProfile("id-identity") + require.NotNil(t, pcp, "projected entry must be present after refresh") } diff --git a/pkg/objectcache/containerprofilecache/t8_overlay_refresh_test.go b/pkg/objectcache/containerprofilecache/t8_overlay_refresh_test.go index ea67a5d172..3802e52b3e 100644 --- a/pkg/objectcache/containerprofilecache/t8_overlay_refresh_test.go +++ b/pkg/objectcache/containerprofilecache/t8_overlay_refresh_test.go @@ -75,9 +75,8 @@ func TestT8_EndToEndRefreshUpdatesProjection(t *testing.T) { const id = "c1" // Seed a projected entry with a stale UserAPRV so refresh sees the RV change. - // The Profile here is just the base CP; the reconciler will re-project on refresh. cache.SeedEntryWithOverlayForTest(id, &cpc.CachedContainerProfile{ - Profile: cp, + Projected: cpc.Apply(nil, cp, nil), State: &objectcache.ProfileState{Name: cp.Name}, ContainerName: "nginx", PodName: "nginx-abc", @@ -86,7 +85,6 @@ func TestT8_EndToEndRefreshUpdatesProjection(t *testing.T) { CPName: "cp", RV: "100", UserAPRV: "50", // stale — triggers rebuild when storage returns RV=51 - Shared: false, }, "default", "override", "", "") // Advance storage to apV2 (RV=51). The reconciler will see the RV mismatch @@ -95,14 +93,18 @@ func TestT8_EndToEndRefreshUpdatesProjection(t *testing.T) { store.ap = apV2 store.mu.Unlock() + cache.SetProjectionSpec(objectcache.RuleProjectionSpec{ + Execs: objectcache.FieldSpec{InUse: true, All: true}, + Hash: "test-execs", + }) cache.RefreshAllEntriesForTest(context.Background()) - stored := cache.GetContainerProfile(id) - require.NotNil(t, stored, "entry must remain after refresh") + pcp := cache.GetProjectedContainerProfile(id) + require.NotNil(t, pcp, "entry must remain after refresh") var paths []string - for _, e := range stored.Spec.Execs { - paths = append(paths, e.Path) + for path := range pcp.Execs.Values { + paths = append(paths, path) } assert.Contains(t, paths, "/bin/base", "base CP exec must be preserved after overlay refresh") assert.Contains(t, paths, "/bin/new", "new user-AP exec must appear in the rebuilt projection") diff --git a/pkg/objectcache/containerprofilecache_interface.go b/pkg/objectcache/containerprofilecache_interface.go index fcf73ab9e9..b5bff33ac8 100644 --- a/pkg/objectcache/containerprofilecache_interface.go +++ b/pkg/objectcache/containerprofilecache_interface.go @@ -7,13 +7,17 @@ import ( containercollection "github.com/inspektor-gadget/inspektor-gadget/pkg/container-collection" "github.com/kubescape/node-agent/pkg/objectcache/callstackcache" - "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" ) +// ContainerProfileCache is the interface satisfied by ContainerProfileCacheImpl +// and its test mocks. GetProjectedContainerProfile replaces the former +// GetContainerProfile — callers receive the compact projected form instead of +// the raw CRD pointer. type ContainerProfileCache interface { - GetContainerProfile(containerID string) *v1beta1.ContainerProfile + GetProjectedContainerProfile(containerID string) *ProjectedContainerProfile GetContainerProfileState(containerID string) *ProfileState GetCallStackSearchTree(containerID string) *callstackcache.CallStackSearchTree + SetProjectionSpec(spec RuleProjectionSpec) ContainerCallback(notif containercollection.PubSubEvent) Start(ctx context.Context) } @@ -22,7 +26,7 @@ var _ ContainerProfileCache = (*ContainerProfileCacheMock)(nil) type ContainerProfileCacheMock struct{} -func (cp *ContainerProfileCacheMock) GetContainerProfile(_ string) *v1beta1.ContainerProfile { +func (cp *ContainerProfileCacheMock) GetProjectedContainerProfile(_ string) *ProjectedContainerProfile { return nil } @@ -34,8 +38,8 @@ func (cp *ContainerProfileCacheMock) GetCallStackSearchTree(_ string) *callstack return nil } -func (cp *ContainerProfileCacheMock) ContainerCallback(_ containercollection.PubSubEvent) { -} +func (cp *ContainerProfileCacheMock) SetProjectionSpec(_ RuleProjectionSpec) {} -func (cp *ContainerProfileCacheMock) Start(_ context.Context) { -} +func (cp *ContainerProfileCacheMock) ContainerCallback(_ containercollection.PubSubEvent) {} + +func (cp *ContainerProfileCacheMock) Start(_ context.Context) {} diff --git a/pkg/objectcache/projection_types.go b/pkg/objectcache/projection_types.go new file mode 100644 index 0000000000..ed55d671b6 --- /dev/null +++ b/pkg/objectcache/projection_types.go @@ -0,0 +1,71 @@ +package objectcache + +import ( + "github.com/kubescape/node-agent/pkg/objectcache/callstackcache" + "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" +) + +// PathMatcher is implemented by the trie-based matchers in containerprofilecache. +type PathMatcher interface { + HasMatch(s string) bool +} + +// RuleProjectionSpec is the compiled, immutable, hash-tagged union of all +// loaded rules' ProfileDataRequired declarations. +type RuleProjectionSpec struct { + Opens FieldSpec + Execs FieldSpec + Capabilities FieldSpec + Syscalls FieldSpec + Endpoints FieldSpec + EgressDomains FieldSpec + EgressAddresses FieldSpec + IngressDomains FieldSpec + IngressAddresses FieldSpec + + Hash string // canonical FNV-64a content hash; populated by CompileSpec +} + +// FieldSpec is the per-data-surface compiled declaration. +type FieldSpec struct { + InUse bool + All bool + Exact map[string]struct{} + Prefixes []string + Suffixes []string + Contains []string + + // PrefixMatcher and SuffixMatcher are compiled by containerprofilecache.CompileSpec. + // They are exported interfaces so CompileSpec (in a different package) can assign them. + PrefixMatcher PathMatcher + SuffixMatcher PathMatcher +} + +// ProjectedContainerProfile is the cache-resident compact form. Pure node-agent +// internal type; never serialized. Replaces *v1beta1.ContainerProfile in the cache. +type ProjectedContainerProfile struct { + Opens ProjectedField + Execs ProjectedField + Endpoints ProjectedField + Capabilities ProjectedField + Syscalls ProjectedField + EgressDomains ProjectedField + EgressAddresses ProjectedField + IngressDomains ProjectedField + IngressAddresses ProjectedField + + SpecHash string + SyncChecksum string + PolicyByRuleId map[string]v1beta1.RulePolicy + CallStackTree *callstackcache.CallStackSearchTree +} + +// ProjectedField is the per-surface compact form read by CEL helpers. +// Composite-key carriers (flags, args, methods, ports) are out of scope for v1. +type ProjectedField struct { + All bool + Values map[string]struct{} + Patterns []string + PrefixHits map[string]bool + SuffixHits map[string]bool +} diff --git a/pkg/objectcache/v1/mock.go b/pkg/objectcache/v1/mock.go index 98c41e0db3..c618e24506 100644 --- a/pkg/objectcache/v1/mock.go +++ b/pkg/objectcache/v1/mock.go @@ -3,6 +3,7 @@ package objectcache import ( "context" "errors" + "sync" corev1 "k8s.io/api/core/v1" @@ -38,6 +39,9 @@ type RuleObjectCacheMock struct { cpByContainerName map[string]*v1beta1.ContainerProfile dnsCache map[string]string ContainerIDToSharedData *maps.SafeMap[string, *objectcache.WatchedContainerData] + + projectionSpecMu sync.RWMutex + projectionSpec objectcache.RuleProjectionSpec } func (r *RuleObjectCacheMock) GetApplicationProfile(string) *v1beta1.ApplicationProfile { @@ -111,6 +115,129 @@ func (r *RuleObjectCacheMock) GetContainerProfile(containerID string) *v1beta1.C return r.cp } +func (r *RuleObjectCacheMock) GetProjectedContainerProfile(containerID string) *objectcache.ProjectedContainerProfile { + cp := r.GetContainerProfile(containerID) + if cp == nil { + return nil + } + r.projectionSpecMu.RLock() + spec := r.projectionSpec + r.projectionSpecMu.RUnlock() + // When no spec has been installed (Hash==""), expose all raw data so + // single-surface unit tests that never call SetProjectionSpec still work. + // When a spec is installed, only populate surfaces that are InUse, matching + // production behaviour where unrequested fields are dropped by Apply(). + specInstalled := spec.Hash != "" + + pcp := &objectcache.ProjectedContainerProfile{ + PolicyByRuleId: cp.Spec.PolicyByRuleId, + SpecHash: spec.Hash, + } + + if (!specInstalled || spec.Capabilities.InUse) && len(cp.Spec.Capabilities) > 0 { + pcp.Capabilities.All = true + pcp.Capabilities.Values = make(map[string]struct{}, len(cp.Spec.Capabilities)) + for _, c := range cp.Spec.Capabilities { + pcp.Capabilities.Values[c] = struct{}{} + } + } + + if (!specInstalled || spec.Syscalls.InUse) && len(cp.Spec.Syscalls) > 0 { + pcp.Syscalls.All = true + pcp.Syscalls.Values = make(map[string]struct{}, len(cp.Spec.Syscalls)) + for _, s := range cp.Spec.Syscalls { + pcp.Syscalls.Values[s] = struct{}{} + } + } + + if (!specInstalled || spec.Execs.InUse) && len(cp.Spec.Execs) > 0 { + pcp.Execs.All = true + pcp.Execs.Values = make(map[string]struct{}, len(cp.Spec.Execs)) + for _, e := range cp.Spec.Execs { + pcp.Execs.Values[e.Path] = struct{}{} + } + } + + if (!specInstalled || spec.Opens.InUse) && len(cp.Spec.Opens) > 0 { + pcp.Opens.All = true + pcp.Opens.Values = make(map[string]struct{}, len(cp.Spec.Opens)) + for _, o := range cp.Spec.Opens { + pcp.Opens.Values[o.Path] = struct{}{} + } + } + + if (!specInstalled || spec.Endpoints.InUse) && len(cp.Spec.Endpoints) > 0 { + pcp.Endpoints.All = true + pcp.Endpoints.Values = make(map[string]struct{}, len(cp.Spec.Endpoints)) + for _, e := range cp.Spec.Endpoints { + pcp.Endpoints.Values[e.Endpoint] = struct{}{} + } + } + + // Egress addresses and domains — All=true: all observed entries are retained. + if !specInstalled || spec.EgressAddresses.InUse || spec.EgressDomains.InUse { + for _, n := range cp.Spec.Egress { + if (!specInstalled || spec.EgressAddresses.InUse) && n.IPAddress != "" { + if pcp.EgressAddresses.Values == nil { + pcp.EgressAddresses.All = true + pcp.EgressAddresses.Values = make(map[string]struct{}) + } + pcp.EgressAddresses.Values[n.IPAddress] = struct{}{} + } + if !specInstalled || spec.EgressDomains.InUse { + domains := n.DNSNames + if n.DNS != "" { + domains = append([]string{n.DNS}, domains...) + } + for _, d := range domains { + if pcp.EgressDomains.Values == nil { + pcp.EgressDomains.All = true + pcp.EgressDomains.Values = make(map[string]struct{}) + } + pcp.EgressDomains.Values[d] = struct{}{} + } + } + } + } + + // Ingress addresses and domains — All=true: all observed entries are retained. + if !specInstalled || spec.IngressAddresses.InUse || spec.IngressDomains.InUse { + for _, n := range cp.Spec.Ingress { + if (!specInstalled || spec.IngressAddresses.InUse) && n.IPAddress != "" { + if pcp.IngressAddresses.Values == nil { + pcp.IngressAddresses.All = true + pcp.IngressAddresses.Values = make(map[string]struct{}) + } + pcp.IngressAddresses.Values[n.IPAddress] = struct{}{} + } + if !specInstalled || spec.IngressDomains.InUse { + if n.DNS != "" { + if pcp.IngressDomains.Values == nil { + pcp.IngressDomains.All = true + pcp.IngressDomains.Values = make(map[string]struct{}) + } + pcp.IngressDomains.Values[n.DNS] = struct{}{} + } + for _, d := range n.DNSNames { + if pcp.IngressDomains.Values == nil { + pcp.IngressDomains.All = true + pcp.IngressDomains.Values = make(map[string]struct{}) + } + pcp.IngressDomains.Values[d] = struct{}{} + } + } + } + } + + return pcp +} + +func (r *RuleObjectCacheMock) SetProjectionSpec(spec objectcache.RuleProjectionSpec) { + r.projectionSpecMu.Lock() + r.projectionSpec = spec + r.projectionSpecMu.Unlock() +} + func (r *RuleObjectCacheMock) SetContainerProfile(cp *v1beta1.ContainerProfile) { r.cp = cp } diff --git a/pkg/rulebindingmanager/cache/cache.go b/pkg/rulebindingmanager/cache/cache.go index af67961e64..9ca100082b 100644 --- a/pkg/rulebindingmanager/cache/cache.go +++ b/pkg/rulebindingmanager/cache/cache.go @@ -187,12 +187,19 @@ func (c *RBCache) DeleteHandler(_ context.Context, obj runtime.Object) { func (c *RBCache) RefreshRuleBindingsRules() { c.mutex.Lock() - defer c.mutex.Unlock() for _, rbName := range c.rbNameToRB.Keys() { rb := c.rbNameToRB.Get(rbName) c.rbNameToRules.Set(rbName, c.createRules(rb.Spec.Rules)) } logger.L().Info("RBCache - refreshed rule bindings rules", helpers.Int("ruleBindings", len(c.rbNameToRB.Keys()))) + // Snapshot notifiers while holding the lock, then release before sending to + // avoid blocking cache operations if any notifier channel is full. + notifiers := make([]*chan rulebindingmanager.RuleBindingNotify, len(c.notifiers)) + copy(notifiers, c.notifiers) + c.mutex.Unlock() + for _, n := range notifiers { + *n <- rulebindingmanager.RuleBindingNotify{} + } } // ----------------- RuleBinding manager methods ----------------- diff --git a/pkg/rulemanager/cel/cel.go b/pkg/rulemanager/cel/cel.go index ef7a393d73..b064323df9 100644 --- a/pkg/rulemanager/cel/cel.go +++ b/pkg/rulemanager/cel/cel.go @@ -12,6 +12,7 @@ import ( "github.com/kubescape/go-logger/helpers" "github.com/kubescape/node-agent/pkg/config" "github.com/kubescape/node-agent/pkg/ebpf/events" + "github.com/kubescape/node-agent/pkg/metricsmanager" "github.com/kubescape/node-agent/pkg/objectcache" "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/applicationprofile" "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/k8s" @@ -38,7 +39,7 @@ type CEL struct { staticOptimizer *cel.StaticOptimizer } -func NewCEL(objectCache objectcache.ObjectCache, cfg config.Config) (*CEL, error) { +func NewCEL(objectCache objectcache.ObjectCache, cfg config.Config, mm ...metricsmanager.MetricsManager) (*CEL, error) { ta, tp := xcel.NewTypeAdapter(), xcel.NewTypeProvider() eventObj, eventTyp := xcel.NewObject(&utils.CelEventImpl{}) @@ -61,8 +62,8 @@ func NewCEL(objectCache objectcache.ObjectCache, cfg config.Config) (*CEL, error cel.CustomTypeProvider(tp), ext.Strings(), k8s.K8s(objectCache.K8sObjectCache(), cfg), - applicationprofile.AP(objectCache, cfg), - networkneighborhood.NN(objectCache, cfg), + applicationprofile.AP(objectCache, cfg, mm...), + networkneighborhood.NN(objectCache, cfg, mm...), parse.Parse(cfg), net.Net(cfg), process.Process(cfg), diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/ap.go b/pkg/rulemanager/cel/libraries/applicationprofile/ap.go index 2a87b26497..ce86d7ab88 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/ap.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/ap.go @@ -6,30 +6,38 @@ import ( "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/kubescape/node-agent/pkg/config" + "github.com/kubescape/node-agent/pkg/metricsmanager" "github.com/kubescape/node-agent/pkg/objectcache" "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries" "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache" ) -func New(objectCache objectcache.ObjectCache, config config.Config) libraries.Library { - return &apLibrary{ +func New(objectCache objectcache.ObjectCache, config config.Config, mm ...metricsmanager.MetricsManager) libraries.Library { + lib := &apLibrary{ objectCache: objectCache, functionCache: cache.NewFunctionCache(cache.FunctionCacheConfig{ MaxSize: config.CelConfigCache.MaxSize, TTL: config.CelConfigCache.TTL, }), - preStopCache: GetPreStopHookCache(), + preStopCache: GetPreStopHookCache(), + detailedMetrics: config.ProfileProjection.DetailedMetricsEnabled, } + if len(mm) > 0 { + lib.metrics = mm[0] + } + return lib } -func AP(objectCache objectcache.ObjectCache, config config.Config) cel.EnvOption { - return cel.Lib(New(objectCache, config)) +func AP(objectCache objectcache.ObjectCache, config config.Config, mm ...metricsmanager.MetricsManager) cel.EnvOption { + return cel.Lib(New(objectCache, config, mm...)) } type apLibrary struct { - objectCache objectcache.ObjectCache - functionCache *cache.FunctionCache - preStopCache *PreStopHookCache + objectCache objectcache.ObjectCache + functionCache *cache.FunctionCache + preStopCache *PreStopHookCache + metrics metricsmanager.MetricsManager + detailedMetrics bool } func (l *apLibrary) LibraryName() string { @@ -49,10 +57,13 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { if len(values) != 2 { return types.NewErr("expected 2 arguments, got %d", len(values)) } + if l.detailedMetrics && l.metrics != nil { + l.metrics.IncHelperCall("ap.was_executed") + } wrapperFunc := func(args ...ref.Val) ref.Val { return l.wasExecuted(args[0], args[1]) } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_executed") + cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_executed", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) // Convert "profile not available" error to false after cache layer // This ensures: 1) error is not cached, 2) rule evaluation continues normally @@ -67,10 +78,13 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { if len(values) != 3 { return types.NewErr("expected 3 arguments, got %d", len(values)) } + if l.detailedMetrics && l.metrics != nil { + l.metrics.IncHelperCall("ap.was_executed_with_args") + } wrapperFunc := func(args ...ref.Val) ref.Val { return l.wasExecutedWithArgs(args[0], args[1], args[2]) } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_executed_with_args") + cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_executed_with_args", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1], values[2]) // Convert "profile not available" error to false after cache layer // This ensures: 1) error is not cached, 2) rule evaluation continues normally @@ -85,10 +99,13 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { if len(values) != 2 { return types.NewErr("expected 2 arguments, got %d", len(values)) } + if l.detailedMetrics && l.metrics != nil { + l.metrics.IncHelperCall("ap.was_path_opened") + } wrapperFunc := func(args ...ref.Val) ref.Val { return l.wasPathOpened(args[0], args[1]) } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_path_opened") + cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_path_opened", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), @@ -101,10 +118,13 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { if len(values) != 3 { return types.NewErr("expected 3 arguments, got %d", len(values)) } + if l.detailedMetrics && l.metrics != nil { + l.metrics.IncHelperCall("ap.was_path_opened_with_flags") + } wrapperFunc := func(args ...ref.Val) ref.Val { return l.wasPathOpenedWithFlags(args[0], args[1], args[2]) } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_path_opened_with_flags") + cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_path_opened_with_flags", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1], values[2]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), @@ -117,10 +137,13 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { if len(values) != 2 { return types.NewErr("expected 2 arguments, got %d", len(values)) } + if l.detailedMetrics && l.metrics != nil { + l.metrics.IncHelperCall("ap.was_path_opened_with_suffix") + } wrapperFunc := func(args ...ref.Val) ref.Val { return l.wasPathOpenedWithSuffix(args[0], args[1]) } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_path_opened_with_suffix") + cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_path_opened_with_suffix", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), @@ -133,10 +156,13 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { if len(values) != 2 { return types.NewErr("expected 2 arguments, got %d", len(values)) } + if l.detailedMetrics && l.metrics != nil { + l.metrics.IncHelperCall("ap.was_path_opened_with_prefix") + } wrapperFunc := func(args ...ref.Val) ref.Val { return l.wasPathOpenedWithPrefix(args[0], args[1]) } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_path_opened_with_prefix") + cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_path_opened_with_prefix", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), @@ -149,10 +175,13 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { if len(values) != 2 { return types.NewErr("expected 2 arguments, got %d", len(values)) } + if l.detailedMetrics && l.metrics != nil { + l.metrics.IncHelperCall("ap.was_syscall_used") + } wrapperFunc := func(args ...ref.Val) ref.Val { return l.wasSyscallUsed(args[0], args[1]) } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_syscall_used") + cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_syscall_used", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), @@ -165,10 +194,13 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { if len(values) != 2 { return types.NewErr("expected 2 arguments, got %d", len(values)) } + if l.detailedMetrics && l.metrics != nil { + l.metrics.IncHelperCall("ap.was_capability_used") + } wrapperFunc := func(args ...ref.Val) ref.Val { return l.wasCapabilityUsed(args[0], args[1]) } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_capability_used") + cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_capability_used", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), @@ -181,10 +213,13 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { if len(values) != 2 { return types.NewErr("expected 2 arguments, got %d", len(values)) } + if l.detailedMetrics && l.metrics != nil { + l.metrics.IncHelperCall("ap.was_endpoint_accessed") + } wrapperFunc := func(args ...ref.Val) ref.Val { return l.wasEndpointAccessed(args[0], args[1]) } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_endpoint_accessed") + cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_endpoint_accessed", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), @@ -197,10 +232,13 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { if len(values) != 3 { return types.NewErr("expected 3 arguments, got %d", len(values)) } + if l.detailedMetrics && l.metrics != nil { + l.metrics.IncHelperCall("ap.was_endpoint_accessed_with_method") + } wrapperFunc := func(args ...ref.Val) ref.Val { return l.wasEndpointAccessedWithMethod(args[0], args[1], args[2]) } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_endpoint_accessed_with_method") + cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_endpoint_accessed_with_method", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1], values[2]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), @@ -213,10 +251,13 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { if len(values) != 3 { return types.NewErr("expected 3 arguments, got %d", len(values)) } + if l.detailedMetrics && l.metrics != nil { + l.metrics.IncHelperCall("ap.was_endpoint_accessed_with_methods") + } wrapperFunc := func(args ...ref.Val) ref.Val { return l.wasEndpointAccessedWithMethods(args[0], args[1], args[2]) } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_endpoint_accessed_with_methods") + cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_endpoint_accessed_with_methods", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1], values[2]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), @@ -229,10 +270,13 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { if len(values) != 2 { return types.NewErr("expected 2 arguments, got %d", len(values)) } + if l.detailedMetrics && l.metrics != nil { + l.metrics.IncHelperCall("ap.was_endpoint_accessed_with_prefix") + } wrapperFunc := func(args ...ref.Val) ref.Val { return l.wasEndpointAccessedWithPrefix(args[0], args[1]) } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_endpoint_accessed_with_prefix") + cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_endpoint_accessed_with_prefix", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), @@ -245,10 +289,13 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { if len(values) != 2 { return types.NewErr("expected 2 arguments, got %d", len(values)) } + if l.detailedMetrics && l.metrics != nil { + l.metrics.IncHelperCall("ap.was_endpoint_accessed_with_suffix") + } wrapperFunc := func(args ...ref.Val) ref.Val { return l.wasEndpointAccessedWithSuffix(args[0], args[1]) } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_endpoint_accessed_with_suffix") + cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_endpoint_accessed_with_suffix", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), @@ -261,10 +308,13 @@ func (l *apLibrary) Declarations() map[string][]cel.FunctionOpt { if len(values) != 2 { return types.NewErr("expected 2 arguments, got %d", len(values)) } + if l.detailedMetrics && l.metrics != nil { + l.metrics.IncHelperCall("ap.was_host_accessed") + } wrapperFunc := func(args ...ref.Val) ref.Val { return l.wasHostAccessed(args[0], args[1]) } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_host_accessed") + cachedFunc := l.functionCache.WithCache(wrapperFunc, "ap.was_host_accessed", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/capability.go b/pkg/rulemanager/cel/libraries/applicationprofile/capability.go index 13cbc0866c..eb3919f9ac 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/capability.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/capability.go @@ -1,8 +1,6 @@ package applicationprofile import ( - "slices" - "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache" @@ -23,12 +21,12 @@ func (l *apLibrary) wasCapabilityUsed(containerID, capabilityName ref.Val) ref.V return types.MaybeNoSuchOverloadErr(capabilityName) } - cp, _, err := profilehelper.GetContainerProfile(l.objectCache, containerIDStr) + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - if slices.Contains(cp.Spec.Capabilities, capabilityNameStr) { + if _, ok := cp.Capabilities.Values[capabilityNameStr]; ok { return types.Bool(true) } diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go index 25b92f2366..b69a69c0ea 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/exec.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/exec.go @@ -1,8 +1,6 @@ package applicationprofile import ( - "slices" - "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" @@ -11,6 +9,7 @@ import ( "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache" "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/celparse" "github.com/kubescape/node-agent/pkg/rulemanager/profilehelper" + "github.com/kubescape/storage/pkg/registry/file/dynamicpathdetector" ) func (l *apLibrary) wasExecuted(containerID, path ref.Val) ref.Val { @@ -32,15 +31,19 @@ func (l *apLibrary) wasExecuted(containerID, path ref.Val) ref.Val { return types.Bool(true) } - cp, _, err := profilehelper.GetContainerProfile(l.objectCache, containerIDStr) + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { // Return a special error that will NOT be cached, allowing retry when profile becomes available. // The caller should convert this to false after the cache layer. return cache.NewProfileNotAvailableErr("%v", err) } - for _, exec := range cp.Spec.Execs { - if exec.Path == pathStr { + if _, ok := cp.Execs.Values[pathStr]; ok { + return types.Bool(true) + } + // Check Patterns (dynamic-segment entries). + for _, execPath := range cp.Execs.Patterns { + if dynamicpathdetector.CompareDynamic(execPath, pathStr) { return types.Bool(true) } } @@ -67,8 +70,12 @@ func (l *apLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val return types.MaybeNoSuchOverloadErr(path) } - celArgs, err := celparse.ParseList[string](args) - if err != nil { + // v1 limitation for rule authors: wasExecutedWithArgs is currently equivalent + // to wasExecuted — the args list is validated but not matched against. Any + // execution of the given path returns true regardless of its arguments. Full + // argument matching (ExecArgsByPath) will be added in a future version. + _ = args + if _, err := celparse.ParseList[string](args); err != nil { return types.NewErr("failed to parse args: %v", err) } @@ -77,18 +84,20 @@ func (l *apLibrary) wasExecutedWithArgs(containerID, path, args ref.Val) ref.Val return types.Bool(true) } - cp, _, err := profilehelper.GetContainerProfile(l.objectCache, containerIDStr) + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { // Return a special error that will NOT be cached, allowing retry when profile becomes available. // The caller should convert this to false after the cache layer. return cache.NewProfileNotAvailableErr("%v", err) } - for _, exec := range cp.Spec.Execs { - if exec.Path == pathStr { - if slices.Compare(exec.Args, celArgs) == 0 { - return types.Bool(true) - } + if _, ok := cp.Execs.Values[pathStr]; ok { + return types.Bool(true) + } + // Check Patterns (dynamic-segment entries). + for _, execPath := range cp.Execs.Patterns { + if dynamicpathdetector.CompareDynamic(execPath, pathStr) { + return types.Bool(true) } } diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go b/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go index 8821e7bdfd..085e2215fc 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/exec_test.go @@ -200,11 +200,12 @@ func TestExecWithArgsInProfile(t *testing.T) { expectedResult: true, }, { + // v1 degradation: args projection is out of scope; path-only matching. name: "Path matches but args don't match", containerID: "test-container-id", path: "/bin/ls", args: []string{"-la", "/home"}, - expectedResult: false, + expectedResult: true, }, { name: "Path doesn't exist", @@ -228,11 +229,12 @@ func TestExecWithArgsInProfile(t *testing.T) { expectedResult: true, }, { + // v1 degradation: args projection is out of scope; path-only matching. name: "Empty args list", containerID: "test-container-id", path: "/bin/ls", args: []string{}, - expectedResult: false, + expectedResult: true, }, } diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/http.go b/pkg/rulemanager/cel/libraries/applicationprofile/http.go index fe91609a55..45cfb19a5b 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/http.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/http.go @@ -2,11 +2,12 @@ package applicationprofile import ( "net/url" - "slices" "strings" "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" + "github.com/kubescape/go-logger" + "github.com/kubescape/go-logger/helpers" "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache" "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/celparse" "github.com/kubescape/node-agent/pkg/rulemanager/profilehelper" @@ -28,13 +29,18 @@ func (l *apLibrary) wasEndpointAccessed(containerID, endpoint ref.Val) ref.Val { return types.MaybeNoSuchOverloadErr(endpoint) } - cp, _, err := profilehelper.GetContainerProfile(l.objectCache, containerIDStr) + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - for _, ep := range cp.Spec.Endpoints { - if dynamicpathdetector.CompareDynamic(ep.Endpoint, endpointStr) { + for ep := range cp.Endpoints.Values { + if dynamicpathdetector.CompareDynamic(ep, endpointStr) { + return types.Bool(true) + } + } + for _, ep := range cp.Endpoints.Patterns { + if dynamicpathdetector.CompareDynamic(ep, endpointStr) { return types.Bool(true) } } @@ -56,21 +62,24 @@ func (l *apLibrary) wasEndpointAccessedWithMethod(containerID, endpoint, method if !ok { return types.MaybeNoSuchOverloadErr(endpoint) } - methodStr, ok := method.Value().(string) - if !ok { + if _, ok := method.Value().(string); !ok { return types.MaybeNoSuchOverloadErr(method) } - cp, _, err := profilehelper.GetContainerProfile(l.objectCache, containerIDStr) + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - for _, ep := range cp.Spec.Endpoints { - if dynamicpathdetector.CompareDynamic(ep.Endpoint, endpointStr) { - if slices.Contains(ep.Methods, methodStr) { - return types.Bool(true) - } + // EndpointMethodsByPath is out of scope for v1 — check path membership only. + for ep := range cp.Endpoints.Values { + if dynamicpathdetector.CompareDynamic(ep, endpointStr) { + return types.Bool(true) + } + } + for _, ep := range cp.Endpoints.Patterns { + if dynamicpathdetector.CompareDynamic(ep, endpointStr) { + return types.Bool(true) } } @@ -92,23 +101,24 @@ func (l *apLibrary) wasEndpointAccessedWithMethods(containerID, endpoint, method return types.MaybeNoSuchOverloadErr(endpoint) } - celMethods, err := celparse.ParseList[string](methods) - if err != nil { + if _, err := celparse.ParseList[string](methods); err != nil { return types.NewErr("failed to parse methods: %v", err) } - cp, _, err := profilehelper.GetContainerProfile(l.objectCache, containerIDStr) + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - for _, ep := range cp.Spec.Endpoints { - if dynamicpathdetector.CompareDynamic(ep.Endpoint, endpointStr) { - for _, method := range celMethods { - if slices.Contains(ep.Methods, method) { - return types.Bool(true) - } - } + // EndpointMethodsByPath is out of scope for v1 — check path membership only. + for ep := range cp.Endpoints.Values { + if dynamicpathdetector.CompareDynamic(ep, endpointStr) { + return types.Bool(true) + } + } + for _, ep := range cp.Endpoints.Patterns { + if dynamicpathdetector.CompareDynamic(ep, endpointStr) { + return types.Bool(true) } } @@ -130,18 +140,34 @@ func (l *apLibrary) wasEndpointAccessedWithPrefix(containerID, prefix ref.Val) r return types.MaybeNoSuchOverloadErr(prefix) } - cp, _, err := profilehelper.GetContainerProfile(l.objectCache, containerIDStr) + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - for _, ep := range cp.Spec.Endpoints { - if strings.HasPrefix(ep.Endpoint, prefixStr) { - return types.Bool(true) + if cp.Endpoints.All { + // All entries retained — scan to check for the prefix. + for ep := range cp.Endpoints.Values { + if strings.HasPrefix(ep, prefixStr) { + return types.Bool(true) + } } + for _, ep := range cp.Endpoints.Patterns { + if strings.HasPrefix(ep, prefixStr) { + return types.Bool(true) + } + } + return types.Bool(false) } - - return types.Bool(false) + // Projection applied — PrefixHits is authoritative; absent key = undeclared. + hit, declared := cp.Endpoints.PrefixHits[prefixStr] + if !declared { + if l.metrics != nil { + l.metrics.IncProjectionUndeclaredLiteral("ap.was_endpoint_accessed_with_prefix") + } + return types.Bool(false) + } + return types.Bool(hit) } // wasEndpointAccessedWithSuffix checks if any HTTP endpoint with the specified suffix was accessed @@ -159,18 +185,34 @@ func (l *apLibrary) wasEndpointAccessedWithSuffix(containerID, suffix ref.Val) r return types.MaybeNoSuchOverloadErr(suffix) } - cp, _, err := profilehelper.GetContainerProfile(l.objectCache, containerIDStr) + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - for _, ep := range cp.Spec.Endpoints { - if strings.HasSuffix(ep.Endpoint, suffixStr) { - return types.Bool(true) + if cp.Endpoints.All { + // All entries retained — scan to check for the suffix. + for ep := range cp.Endpoints.Values { + if strings.HasSuffix(ep, suffixStr) { + return types.Bool(true) + } } + for _, ep := range cp.Endpoints.Patterns { + if strings.HasSuffix(ep, suffixStr) { + return types.Bool(true) + } + } + return types.Bool(false) } - - return types.Bool(false) + // Projection applied — SuffixHits is authoritative; absent key = undeclared. + hit, declared := cp.Endpoints.SuffixHits[suffixStr] + if !declared { + if l.metrics != nil { + l.metrics.IncProjectionUndeclaredLiteral("ap.was_endpoint_accessed_with_suffix") + } + return types.Bool(false) + } + return types.Bool(hit) } // wasHostAccessed checks if a specific host was accessed via HTTP endpoints or network connections @@ -189,20 +231,33 @@ func (l *apLibrary) wasHostAccessed(containerID, host ref.Val) ref.Val { } // Check HTTP endpoints for host access - cp, _, err := profilehelper.GetContainerProfile(l.objectCache, containerIDStr) + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - for _, ep := range cp.Spec.Endpoints { + if !cp.Endpoints.All { + // Only a subset of endpoints is retained — results may not reflect the full profile. + logger.L().Debug("was_host_accessed called with Endpoints.All=false; results limited to projected subset", + helpers.String("containerID", containerIDStr), + helpers.String("host", hostStr)) + } + allEndpoints := make([]string, 0, len(cp.Endpoints.Values)+len(cp.Endpoints.Patterns)) + for ep := range cp.Endpoints.Values { + allEndpoints = append(allEndpoints, ep) + } + allEndpoints = append(allEndpoints, cp.Endpoints.Patterns...) + + for _, ep := range allEndpoints { // Parse the endpoint URL to extract host - if parsedURL, err := url.Parse(ep.Endpoint); err == nil && parsedURL.Host != "" { + if parsedURL, err := url.Parse(ep); err == nil && parsedURL.Host != "" { if parsedURL.Host == hostStr || parsedURL.Hostname() == hostStr { return types.Bool(true) } } - // Also check if the endpoint contains the host as a substring (for cases where it's not a full URL) - if strings.Contains(ep.Endpoint, hostStr) { + // For non-URL endpoints check for a whole-token match so that a short + // host like "api" does not match path segments like "/v1/api/users". + if ep == hostStr || strings.HasPrefix(ep, hostStr+"/") || strings.HasPrefix(ep, hostStr+":") { return types.Bool(true) } } diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/open.go b/pkg/rulemanager/cel/libraries/applicationprofile/open.go index 63d8f604a4..ec0a8310c5 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/open.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/open.go @@ -25,13 +25,20 @@ func (l *apLibrary) wasPathOpened(containerID, path ref.Val) ref.Val { return types.MaybeNoSuchOverloadErr(path) } - cp, _, err := profilehelper.GetContainerProfile(l.objectCache, containerIDStr) + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - for _, open := range cp.Spec.Opens { - if dynamicpathdetector.CompareDynamic(open.Path, pathStr) { + // All=true means all observed entries were retained in Values — still need to query Values. + for openPath := range cp.Opens.Values { + if dynamicpathdetector.CompareDynamic(openPath, pathStr) { + return types.Bool(true) + } + } + // Check Patterns (dynamic-segment entries). + for _, openPath := range cp.Opens.Patterns { + if dynamicpathdetector.CompareDynamic(openPath, pathStr) { return types.Bool(true) } } @@ -54,21 +61,24 @@ func (l *apLibrary) wasPathOpenedWithFlags(containerID, path, flags ref.Val) ref return types.MaybeNoSuchOverloadErr(path) } - celFlags, err := celparse.ParseList[string](flags) - if err != nil { + // flags projection (OpenFlagsByPath) is out of scope for v1; degrade to path-only matching. + if _, err := celparse.ParseList[string](flags); err != nil { return types.NewErr("failed to parse flags: %v", err) } - cp, _, err := profilehelper.GetContainerProfile(l.objectCache, containerIDStr) + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - for _, open := range cp.Spec.Opens { - if dynamicpathdetector.CompareDynamic(open.Path, pathStr) { - if compareOpenFlags(celFlags, open.Flags) { - return types.Bool(true) - } + for openPath := range cp.Opens.Values { + if dynamicpathdetector.CompareDynamic(openPath, pathStr) { + return types.Bool(true) + } + } + for _, openPath := range cp.Opens.Patterns { + if dynamicpathdetector.CompareDynamic(openPath, pathStr) { + return types.Bool(true) } } @@ -89,18 +99,34 @@ func (l *apLibrary) wasPathOpenedWithSuffix(containerID, suffix ref.Val) ref.Val return types.MaybeNoSuchOverloadErr(suffix) } - cp, _, err := profilehelper.GetContainerProfile(l.objectCache, containerIDStr) + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - for _, open := range cp.Spec.Opens { - if strings.HasSuffix(open.Path, suffixStr) { - return types.Bool(true) + if cp.Opens.All { + // All entries retained — scan to check for the suffix. + for openPath := range cp.Opens.Values { + if strings.HasSuffix(openPath, suffixStr) { + return types.Bool(true) + } + } + for _, openPath := range cp.Opens.Patterns { + if strings.HasSuffix(openPath, suffixStr) { + return types.Bool(true) + } } + return types.Bool(false) } - - return types.Bool(false) + // Projection applied — SuffixHits is authoritative; absent key = undeclared. + hit, declared := cp.Opens.SuffixHits[suffixStr] + if !declared { + if l.metrics != nil { + l.metrics.IncProjectionUndeclaredLiteral("ap.was_path_opened_with_suffix") + } + return types.Bool(false) + } + return types.Bool(hit) } func (l *apLibrary) wasPathOpenedWithPrefix(containerID, prefix ref.Val) ref.Val { @@ -117,28 +143,33 @@ func (l *apLibrary) wasPathOpenedWithPrefix(containerID, prefix ref.Val) ref.Val return types.MaybeNoSuchOverloadErr(prefix) } - cp, _, err := profilehelper.GetContainerProfile(l.objectCache, containerIDStr) + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - for _, open := range cp.Spec.Opens { - if strings.HasPrefix(open.Path, prefixStr) { - return types.Bool(true) + if cp.Opens.All { + // All entries retained — scan to check for the prefix. + for openPath := range cp.Opens.Values { + if strings.HasPrefix(openPath, prefixStr) { + return types.Bool(true) + } } - } - - return types.Bool(false) -} - -func compareOpenFlags(eventOpenFlags []string, profileOpenFlags []string) bool { - found := 0 - for _, eventOpenFlag := range eventOpenFlags { - for _, profileOpenFlag := range profileOpenFlags { - if eventOpenFlag == profileOpenFlag { - found += 1 + for _, openPath := range cp.Opens.Patterns { + if strings.HasPrefix(openPath, prefixStr) { + return types.Bool(true) } } + return types.Bool(false) + } + // Projection applied — PrefixHits is authoritative; absent key = undeclared. + hit, declared := cp.Opens.PrefixHits[prefixStr] + if !declared { + if l.metrics != nil { + l.metrics.IncProjectionUndeclaredLiteral("ap.was_path_opened_with_prefix") + } + return types.Bool(false) } - return found == len(eventOpenFlags) + return types.Bool(hit) } + diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/open_test.go b/pkg/rulemanager/cel/libraries/applicationprofile/open_test.go index 86bad2b1a0..bf407611e0 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/open_test.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/open_test.go @@ -200,11 +200,12 @@ func TestOpenWithFlagsInProfile(t *testing.T) { expectedResult: true, }, { + // v1 degradation: flags projection is out of scope; path-only matching. name: "Path matches but flags don't match", containerID: "test-container-id", path: "/etc/passwd", flags: []string{"O_WRONLY"}, - expectedResult: false, + expectedResult: true, }, { name: "Path doesn't exist", diff --git a/pkg/rulemanager/cel/libraries/applicationprofile/syscall.go b/pkg/rulemanager/cel/libraries/applicationprofile/syscall.go index 7383aec5ba..3ef066f83f 100644 --- a/pkg/rulemanager/cel/libraries/applicationprofile/syscall.go +++ b/pkg/rulemanager/cel/libraries/applicationprofile/syscall.go @@ -1,8 +1,6 @@ package applicationprofile import ( - "slices" - "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache" @@ -23,12 +21,12 @@ func (l *apLibrary) wasSyscallUsed(containerID, syscallName ref.Val) ref.Val { return types.MaybeNoSuchOverloadErr(syscallName) } - cp, _, err := profilehelper.GetContainerProfile(l.objectCache, containerIDStr) + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - if slices.Contains(cp.Spec.Syscalls, syscallNameStr) { + if _, ok := cp.Syscalls.Values[syscallNameStr]; ok { return types.Bool(true) } diff --git a/pkg/rulemanager/cel/libraries/cache/function_cache.go b/pkg/rulemanager/cel/libraries/cache/function_cache.go index 8ebb01e82c..ba07eafcd3 100644 --- a/pkg/rulemanager/cel/libraries/cache/function_cache.go +++ b/pkg/rulemanager/cel/libraries/cache/function_cache.go @@ -8,6 +8,7 @@ import ( "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/hashicorp/golang-lru/v2/expirable" + "github.com/kubescape/node-agent/pkg/objectcache" ) // ProfileNotAvailableErr is a sentinel error message used to indicate that a profile @@ -78,9 +79,42 @@ func NewFunctionCache(config FunctionCacheConfig) *FunctionCache { type CelFunction func(...ref.Val) ref.Val -func (fc *FunctionCache) WithCache(fn CelFunction, functionName string) CelFunction { +// 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. +func HashForContainerProfile(oc objectcache.ObjectCache) func([]ref.Val) string { + return func(values []ref.Val) string { + if len(values) == 0 || oc == nil { + return "" + } + containerIDStr, ok := values[0].Value().(string) + if !ok { + return "" + } + cpc := oc.ContainerProfileCache() + if cpc == nil { + return "" + } + pcp := cpc.GetProjectedContainerProfile(containerIDStr) + if pcp == nil { + return "" + } + // 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 + } +} + +// WithCache wraps fn with an LRU result cache keyed by functionName + arguments. +// extraKeyFn, if provided, is called with the argument slice and its return value +// is appended to the key — use HashForContainerProfile to invalidate on spec changes. +func (fc *FunctionCache) WithCache(fn CelFunction, functionName string, extraKeyFn ...func([]ref.Val) string) CelFunction { return func(values ...ref.Val) ref.Val { key := fc.generateCacheKey(functionName, values...) + for _, fn := range extraKeyFn { + key += "|" + fn(values) + } if cached, found := fc.cache.Get(key); found { return cached diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/integration_test.go b/pkg/rulemanager/cel/libraries/networkneighborhood/integration_test.go index ab06d4afa2..00e6bff710 100644 --- a/pkg/rulemanager/cel/libraries/networkneighborhood/integration_test.go +++ b/pkg/rulemanager/cel/libraries/networkneighborhood/integration_test.go @@ -210,24 +210,28 @@ 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: `nn.was_address_port_protocol_in_egress(containerID, "192.168.1.100", 9999, "TCP")`, - expectedResult: false, + expectedResult: true, }, { + // 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: `nn.was_address_port_protocol_in_ingress(containerID, "172.16.0.10", 9999, "TCP")`, - expectedResult: false, + expectedResult: true, }, { + // v1 degradation: port/protocol projection is out of scope; address IS in profile → true. name: "Check wrong protocol for existing address and port", expression: `nn.was_address_port_protocol_in_egress(containerID, "192.168.1.100", 80, "UDP")`, - expectedResult: false, + expectedResult: true, }, { + // 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: `nn.was_address_port_protocol_in_ingress(containerID, "172.16.0.10", 8080, "UDP")`, - expectedResult: false, + expectedResult: true, }, { name: "Complex network check with port and protocol - egress", @@ -240,9 +244,10 @@ 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: `nn.was_address_port_protocol_in_egress(containerID, "192.168.1.100", 80, "TCP") && nn.was_address_port_protocol_in_egress(containerID, "192.168.1.100", 9999, "TCP")`, - expectedResult: false, + expectedResult: true, }, } diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/network.go b/pkg/rulemanager/cel/libraries/networkneighborhood/network.go index 0449ebf962..7018e479a2 100644 --- a/pkg/rulemanager/cel/libraries/networkneighborhood/network.go +++ b/pkg/rulemanager/cel/libraries/networkneighborhood/network.go @@ -1,13 +1,10 @@ package networkneighborhood import ( - "slices" - "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache" "github.com/kubescape/node-agent/pkg/rulemanager/profilehelper" - "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" ) func (l *nnLibrary) wasAddressInEgress(containerID, address ref.Val) ref.Val { @@ -24,15 +21,13 @@ func (l *nnLibrary) wasAddressInEgress(containerID, address ref.Val) ref.Val { return types.MaybeNoSuchOverloadErr(address) } - cp, _, err := profilehelper.GetContainerProfile(l.objectCache, containerIDStr) + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - for _, egress := range cp.Spec.Egress { - if egress.IPAddress == addressStr { - return types.Bool(true) - } + if _, ok := cp.EgressAddresses.Values[addressStr]; ok { + return types.Bool(true) } return types.Bool(false) @@ -52,15 +47,13 @@ func (l *nnLibrary) wasAddressInIngress(containerID, address ref.Val) ref.Val { return types.MaybeNoSuchOverloadErr(address) } - cp, _, err := profilehelper.GetContainerProfile(l.objectCache, containerIDStr) + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - for _, ingress := range cp.Spec.Ingress { - if ingress.IPAddress == addressStr { - return types.Bool(true) - } + if _, ok := cp.IngressAddresses.Values[addressStr]; ok { + return types.Bool(true) } return types.Bool(false) @@ -80,15 +73,13 @@ func (l *nnLibrary) isDomainInEgress(containerID, domain ref.Val) ref.Val { return types.MaybeNoSuchOverloadErr(domain) } - cp, _, err := profilehelper.GetContainerProfile(l.objectCache, containerIDStr) + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - for _, egress := range cp.Spec.Egress { - if slices.Contains(egress.DNSNames, domainStr) || egress.DNS == domainStr { - return types.Bool(true) - } + if _, ok := cp.EgressDomains.Values[domainStr]; ok { + return types.Bool(true) } return types.Bool(false) @@ -108,15 +99,13 @@ func (l *nnLibrary) isDomainInIngress(containerID, domain ref.Val) ref.Val { return types.MaybeNoSuchOverloadErr(domain) } - cp, _, err := profilehelper.GetContainerProfile(l.objectCache, containerIDStr) + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - for _, ingress := range cp.Spec.Ingress { - if slices.Contains(ingress.DNSNames, domainStr) { - return types.Bool(true) - } + if _, ok := cp.IngressDomains.Values[domainStr]; ok { + return types.Bool(true) } return types.Bool(false) @@ -135,28 +124,21 @@ func (l *nnLibrary) wasAddressPortProtocolInEgress(containerID, address, port, p if !ok { return types.MaybeNoSuchOverloadErr(address) } - portInt, ok := port.Value().(int64) - if !ok { + // port/protocol projection (AddressPortsByAddr) is out of scope for v1; degrade to address-only matching. + if _, ok := port.Value().(int64); !ok { return types.MaybeNoSuchOverloadErr(port) } - protocolStr, ok := protocol.Value().(string) - if !ok { + if _, ok := protocol.Value().(string); !ok { return types.MaybeNoSuchOverloadErr(protocol) } - cp, _, err := profilehelper.GetContainerProfile(l.objectCache, containerIDStr) + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - for _, egress := range cp.Spec.Egress { - if egress.IPAddress == addressStr { - for _, portInfo := range egress.Ports { - if portInfo.Protocol == v1beta1.Protocol(protocolStr) && portInfo.Port != nil && *portInfo.Port == int32(portInt) { - return types.Bool(true) - } - } - } + if _, ok := cp.EgressAddresses.Values[addressStr]; ok { + return types.Bool(true) } return types.Bool(false) @@ -175,28 +157,21 @@ func (l *nnLibrary) wasAddressPortProtocolInIngress(containerID, address, port, if !ok { return types.MaybeNoSuchOverloadErr(address) } - portInt, ok := port.Value().(int64) - if !ok { + // port/protocol projection (AddressPortsByAddr) is out of scope for v1; degrade to address-only matching. + if _, ok := port.Value().(int64); !ok { return types.MaybeNoSuchOverloadErr(port) } - protocolStr, ok := protocol.Value().(string) - if !ok { + if _, ok := protocol.Value().(string); !ok { return types.MaybeNoSuchOverloadErr(protocol) } - cp, _, err := profilehelper.GetContainerProfile(l.objectCache, containerIDStr) + cp, _, err := profilehelper.GetProjectedContainerProfile(l.objectCache, containerIDStr) if err != nil { return cache.NewProfileNotAvailableErr("%v", err) } - for _, ingress := range cp.Spec.Ingress { - if ingress.IPAddress == addressStr { - for _, portInfo := range ingress.Ports { - if portInfo.Protocol == v1beta1.Protocol(protocolStr) && portInfo.Port != nil && *portInfo.Port == int32(portInt) { - return types.Bool(true) - } - } - } + if _, ok := cp.IngressAddresses.Values[addressStr]; ok { + return types.Bool(true) } return types.Bool(false) diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/network_test.go b/pkg/rulemanager/cel/libraries/networkneighborhood/network_test.go index f2e1944c74..8703ed4bab 100644 --- a/pkg/rulemanager/cel/libraries/networkneighborhood/network_test.go +++ b/pkg/rulemanager/cel/libraries/networkneighborhood/network_test.go @@ -100,20 +100,22 @@ func TestWasAddressPortProtocolInEgress(t *testing.T) { expectedResult: true, }, { + // v1 degradation: port/protocol projection is out of scope; address-only matching. name: "Invalid port", containerID: "test-container-id", address: "192.168.1.100", port: 9999, protocol: "TCP", - expectedResult: false, + expectedResult: true, }, { + // v1 degradation: port/protocol projection is out of scope; address-only matching. name: "Invalid protocol", containerID: "test-container-id", address: "192.168.1.100", port: 80, protocol: "UDP", - expectedResult: false, + expectedResult: true, }, { name: "Invalid address", @@ -235,20 +237,22 @@ func TestWasAddressPortProtocolInIngress(t *testing.T) { expectedResult: true, }, { + // v1 degradation: port/protocol projection is out of scope; address-only matching. name: "Invalid port", containerID: "test-container-id", address: "172.16.0.10", port: 9999, protocol: "TCP", - expectedResult: false, + expectedResult: true, }, { + // v1 degradation: port/protocol projection is out of scope; address-only matching. name: "Invalid protocol", containerID: "test-container-id", address: "172.16.0.10", port: 8080, protocol: "UDP", - expectedResult: false, + expectedResult: true, }, { name: "Invalid address", @@ -404,21 +408,20 @@ func TestWasAddressPortProtocolWithNilPort(t *testing.T) { functionCache: cache.NewFunctionCache(cache.DefaultFunctionCacheConfig()), } - // Test egress with nil port + // v1 degradation: address-only matching; nil port in profile no longer checked. result := lib.wasAddressPortProtocolInEgress( types.String("test-container-id"), types.String("192.168.1.100"), types.Int(80), types.String("TCP"), ) - assert.Equal(t, types.Bool(false), result) + assert.Equal(t, types.Bool(true), result) - // Test ingress with nil port result = lib.wasAddressPortProtocolInIngress( types.String("test-container-id"), types.String("172.16.0.10"), types.Int(8080), types.String("TCP"), ) - assert.Equal(t, types.Bool(false), result) + assert.Equal(t, types.Bool(true), result) } diff --git a/pkg/rulemanager/cel/libraries/networkneighborhood/nn.go b/pkg/rulemanager/cel/libraries/networkneighborhood/nn.go index cf9feef93c..fbcf95c60c 100644 --- a/pkg/rulemanager/cel/libraries/networkneighborhood/nn.go +++ b/pkg/rulemanager/cel/libraries/networkneighborhood/nn.go @@ -6,28 +6,36 @@ import ( "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/kubescape/node-agent/pkg/config" + "github.com/kubescape/node-agent/pkg/metricsmanager" "github.com/kubescape/node-agent/pkg/objectcache" "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries" "github.com/kubescape/node-agent/pkg/rulemanager/cel/libraries/cache" ) -func New(objectCache objectcache.ObjectCache, config config.Config) libraries.Library { - return &nnLibrary{ +func New(objectCache objectcache.ObjectCache, config config.Config, mm ...metricsmanager.MetricsManager) libraries.Library { + lib := &nnLibrary{ objectCache: objectCache, functionCache: cache.NewFunctionCache(cache.FunctionCacheConfig{ MaxSize: config.CelConfigCache.MaxSize, TTL: config.CelConfigCache.TTL, }), } + if len(mm) > 0 && mm[0] != nil { + lib.metrics = mm[0] + lib.detailedMetrics = config.ProfileProjection.DetailedMetricsEnabled + } + return lib } -func NN(objectCache objectcache.ObjectCache, config config.Config) cel.EnvOption { - return cel.Lib(New(objectCache, config)) +func NN(objectCache objectcache.ObjectCache, config config.Config, mm ...metricsmanager.MetricsManager) cel.EnvOption { + return cel.Lib(New(objectCache, config, mm...)) } type nnLibrary struct { - objectCache objectcache.ObjectCache - functionCache *cache.FunctionCache + objectCache objectcache.ObjectCache + functionCache *cache.FunctionCache + metrics metricsmanager.MetricsManager + detailedMetrics bool } func (l *nnLibrary) LibraryName() string { @@ -47,10 +55,13 @@ func (l *nnLibrary) Declarations() map[string][]cel.FunctionOpt { if len(values) != 2 { return types.NewErr("expected 2 arguments, got %d", len(values)) } + if l.detailedMetrics && l.metrics != nil { + l.metrics.IncHelperCall("nn.was_address_in_egress") + } wrapperFunc := func(args ...ref.Val) ref.Val { return l.wasAddressInEgress(args[0], args[1]) } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "nn.was_address_in_egress") + cachedFunc := l.functionCache.WithCache(wrapperFunc, "nn.was_address_in_egress", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), @@ -63,10 +74,13 @@ func (l *nnLibrary) Declarations() map[string][]cel.FunctionOpt { if len(values) != 2 { return types.NewErr("expected 2 arguments, got %d", len(values)) } + if l.detailedMetrics && l.metrics != nil { + l.metrics.IncHelperCall("nn.was_address_in_ingress") + } wrapperFunc := func(args ...ref.Val) ref.Val { return l.wasAddressInIngress(args[0], args[1]) } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "nn.was_address_in_ingress") + cachedFunc := l.functionCache.WithCache(wrapperFunc, "nn.was_address_in_ingress", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), @@ -79,10 +93,13 @@ func (l *nnLibrary) Declarations() map[string][]cel.FunctionOpt { if len(values) != 2 { return types.NewErr("expected 2 arguments, got %d", len(values)) } + if l.detailedMetrics && l.metrics != nil { + l.metrics.IncHelperCall("nn.is_domain_in_egress") + } wrapperFunc := func(args ...ref.Val) ref.Val { return l.isDomainInEgress(args[0], args[1]) } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "nn.is_domain_in_egress") + cachedFunc := l.functionCache.WithCache(wrapperFunc, "nn.is_domain_in_egress", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), @@ -95,10 +112,13 @@ func (l *nnLibrary) Declarations() map[string][]cel.FunctionOpt { if len(values) != 2 { return types.NewErr("expected 2 arguments, got %d", len(values)) } + if l.detailedMetrics && l.metrics != nil { + l.metrics.IncHelperCall("nn.is_domain_in_ingress") + } wrapperFunc := func(args ...ref.Val) ref.Val { return l.isDomainInIngress(args[0], args[1]) } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "nn.is_domain_in_ingress") + cachedFunc := l.functionCache.WithCache(wrapperFunc, "nn.is_domain_in_ingress", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), @@ -111,10 +131,13 @@ func (l *nnLibrary) Declarations() map[string][]cel.FunctionOpt { if len(values) != 4 { return types.NewErr("expected 4 arguments, got %d", len(values)) } + if l.detailedMetrics && l.metrics != nil { + l.metrics.IncHelperCall("nn.was_address_port_protocol_in_egress") + } wrapperFunc := func(args ...ref.Val) ref.Val { return l.wasAddressPortProtocolInEgress(args[0], args[1], args[2], args[3]) } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "nn.was_address_port_protocol_in_egress") + cachedFunc := l.functionCache.WithCache(wrapperFunc, "nn.was_address_port_protocol_in_egress", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1], values[2], values[3]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), @@ -127,10 +150,13 @@ func (l *nnLibrary) Declarations() map[string][]cel.FunctionOpt { if len(values) != 4 { return types.NewErr("expected 4 arguments, got %d", len(values)) } + if l.detailedMetrics && l.metrics != nil { + l.metrics.IncHelperCall("nn.was_address_port_protocol_in_ingress") + } wrapperFunc := func(args ...ref.Val) ref.Val { return l.wasAddressPortProtocolInIngress(args[0], args[1], args[2], args[3]) } - cachedFunc := l.functionCache.WithCache(wrapperFunc, "nn.was_address_port_protocol_in_ingress") + cachedFunc := l.functionCache.WithCache(wrapperFunc, "nn.was_address_port_protocol_in_ingress", cache.HashForContainerProfile(l.objectCache)) result := cachedFunc(values[0], values[1], values[2], values[3]) return cache.ConvertProfileNotAvailableErrToBool(result, false) }), diff --git a/pkg/rulemanager/profilehelper/profilehelper.go b/pkg/rulemanager/profilehelper/profilehelper.go index 0f4d5ed0e3..a5a768875d 100644 --- a/pkg/rulemanager/profilehelper/profilehelper.go +++ b/pkg/rulemanager/profilehelper/profilehelper.go @@ -3,25 +3,22 @@ package profilehelper import ( "errors" - "github.com/kubescape/k8s-interface/instanceidhandler/v1/helpers" "github.com/kubescape/node-agent/pkg/objectcache" - "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" corev1 "k8s.io/api/core/v1" ) -// GetContainerProfile returns the ContainerProfile for a containerID plus its -// SyncChecksumMetadataKey annotation. This is the forward API; legacy callers -// go through the shims below until step 6c deletes them. -func GetContainerProfile(objectCache objectcache.ObjectCache, containerID string) (*v1beta1.ContainerProfile, string, error) { +// GetProjectedContainerProfile returns the ProjectedContainerProfile for a containerID plus its +// SyncChecksum annotation value. +func GetProjectedContainerProfile(objectCache objectcache.ObjectCache, containerID string) (*objectcache.ProjectedContainerProfile, string, error) { cpc := objectCache.ContainerProfileCache() if cpc == nil { return nil, "", errors.New("no container profile cache available") } - cp := cpc.GetContainerProfile(containerID) - if cp == nil { + pcp := cpc.GetProjectedContainerProfile(containerID) + if pcp == nil { return nil, "", errors.New("no profile available") } - return cp, cp.Annotations[helpers.SyncChecksumMetadataKey], nil + return pcp, pcp.SyncChecksum, nil } func GetContainerName(objectCache objectcache.ObjectCache, containerID string) string { @@ -52,4 +49,3 @@ func GetPodSpec(objectCache objectcache.ObjectCache, containerID string) (*corev return podSpec, nil } - diff --git a/pkg/rulemanager/rule_manager.go b/pkg/rulemanager/rule_manager.go index a14a5ee86b..ca17060e5e 100644 --- a/pkg/rulemanager/rule_manager.go +++ b/pkg/rulemanager/rule_manager.go @@ -24,6 +24,7 @@ import ( "github.com/kubescape/node-agent/pkg/k8sclient" "github.com/kubescape/node-agent/pkg/metricsmanager" "github.com/kubescape/node-agent/pkg/objectcache" + "github.com/kubescape/node-agent/pkg/objectcache/containerprofilecache" "github.com/kubescape/node-agent/pkg/processtree" bindingcache "github.com/kubescape/node-agent/pkg/rulebindingmanager" "github.com/kubescape/node-agent/pkg/rulemanager/cel" @@ -108,9 +109,100 @@ func CreateRuleManager( detectorManager: detectorManager, } + // Compile the initial projection spec and start a goroutine that + // recompiles whenever rule bindings change. + r.recompileProjectionSpec() + specNotify := make(chan bindingcache.RuleBindingNotify, 10) + ruleBindingCache.AddNotifier(&specNotify) + go func() { + for { + select { + case <-ctx.Done(): + return + case <-specNotify: + // Drain any additional pending notifications so a burst of + // rule-binding updates triggers only one recompile rather than + // one per message (which would also risk filling the channel + // and blocking AddHandler / RefreshRules callers). + for len(specNotify) > 0 { + <-specNotify + } + r.recompileProjectionSpec() + } + } + }() + return r, nil } +// recompileProjectionSpec compiles a RuleProjectionSpec from all currently +// loaded rules and installs it on the ContainerProfileCache. Also runs +// soft-launch validation: rules with profileDependency>0 but no +// profileDataRequired emit an ERROR log (not rejected in default soft mode). +func (rm *RuleManager) recompileProjectionSpec() { + rules := rm.ruleBindingCache.GetRuleCreator().CreateAllRules() + + // Soft-launch validation: rules with profileDependency>0 but no + // profileDataRequired will receive an empty projection. Emit an ERROR + // log and increment the metric; reject (filter out) only in strict mode. + filtered := rules[:0] + for _, r := range rules { + if r.ProfileDependency > 0 && r.ProfileDataRequired == nil { + logger.L().Error("rule has profileDependency but no profileDataRequired — projection will be empty for this rule", + helpers.String("ruleID", r.ID), + helpers.Int("profileDependency", int(r.ProfileDependency))) + rm.metrics.IncMissingProfileDataRequired(r.ID) + if rm.cfg.ProfileProjection.StrictValidation { + continue + } + } + filtered = append(filtered, r) + } + rules = filtered + + // Count rules with no profileDataRequired (pure event-shape rules). + var undeclaredCount float64 + var undeclaredIDs []string + for _, r := range rules { + if r.ProfileDataRequired == nil { + undeclaredCount++ + undeclaredIDs = append(undeclaredIDs, r.ID) + } + } + rm.metrics.SetProjectionUndeclaredRules(undeclaredCount) + + spec := containerprofilecache.CompileSpec(rules) + + if rm.cfg.ProfileProjection.DetailedMetricsEnabled { + rm.metrics.IncProjectionSpecCompile() + rm.metrics.SetProjectionUndeclaredRulesDetail(undeclaredIDs) + type namedField struct { + name string + field *objectcache.FieldSpec + } + fields := []namedField{ + {"opens", &spec.Opens}, + {"execs", &spec.Execs}, + {"capabilities", &spec.Capabilities}, + {"syscalls", &spec.Syscalls}, + {"endpoints", &spec.Endpoints}, + {"egressDomains", &spec.EgressDomains}, + {"egressAddresses", &spec.EgressAddresses}, + {"ingressDomains", &spec.IngressDomains}, + {"ingressAddresses", &spec.IngressAddresses}, + } + for _, nf := range fields { + rm.metrics.SetProjectionSpecPatterns(nf.name, "prefix", float64(len(nf.field.Prefixes))) + rm.metrics.SetProjectionSpecPatterns(nf.name, "suffix", float64(len(nf.field.Suffixes))) + rm.metrics.SetProjectionSpecPatterns(nf.name, "exact", float64(len(nf.field.Exact))) + rm.metrics.SetProjectionSpecPatterns(nf.name, "contains", float64(len(nf.field.Contains))) + rm.metrics.SetProjectionSpecAllField(nf.name, nf.field.All) + } + } + + rm.objectCache.ContainerProfileCache().SetProjectionSpec(spec) +} + func (rm *RuleManager) startRuleManager(container *containercollection.Container, k8sContainerID string) { if utils.IsHostContainer(container) { logger.L().Debug("RuleManager - skipping shared data wait for host container", @@ -200,7 +292,7 @@ func (rm *RuleManager) ReportEnrichedEvent(enrichedEvent *events.EnrichedEvent) return } - _, apChecksum, err := profilehelper.GetContainerProfile(rm.objectCache, enrichedEvent.ContainerID) + _, apChecksum, err := profilehelper.GetProjectedContainerProfile(rm.objectCache, enrichedEvent.ContainerID) profileExists = err == nil // Early exit if monitoring is disabled for this context - skip rule evaluation @@ -345,12 +437,9 @@ func (rm *RuleManager) HasApplicableRuleBindings(namespace, name string) bool { func (rm *RuleManager) HasFinalApplicationProfile(pod *corev1.Pod) bool { for _, c := range utils.GetContainerStatuses(pod.Status) { - cp := rm.objectCache.ContainerProfileCache().GetContainerProfile(utils.TrimRuntimePrefix(c.ContainerID)) - if cp != nil { - if status, ok := cp.Annotations[helpersv1.StatusMetadataKey]; ok { - // in theory, only completed profiles are stored in cache, but we check anyway - return status == helpersv1.Completed - } + state := rm.objectCache.ContainerProfileCache().GetContainerProfileState(utils.TrimRuntimePrefix(c.ContainerID)) + if state != nil && state.Error == nil { + return state.Status == helpersv1.Completed && state.Completion == helpersv1.Full } } return false @@ -410,7 +499,7 @@ func (rm *RuleManager) EvaluatePolicyRulesForEvent(eventType utils.EventType, ev } func (rm *RuleManager) validateRulePolicy(rule typesv1.Rule, event utils.K8sEvent, containerID string) bool { - cp, _, err := profilehelper.GetContainerProfile(rm.objectCache, containerID) + cp, _, err := profilehelper.GetProjectedContainerProfile(rm.objectCache, containerID) if err != nil { return false } diff --git a/pkg/rulemanager/rulepolicy.go b/pkg/rulemanager/rulepolicy.go index f5562b2b2c..d9e8392a1f 100644 --- a/pkg/rulemanager/rulepolicy.go +++ b/pkg/rulemanager/rulepolicy.go @@ -7,7 +7,6 @@ import ( "github.com/kubescape/node-agent/pkg/contextdetection" "github.com/kubescape/node-agent/pkg/objectcache" typesv1 "github.com/kubescape/node-agent/pkg/rulemanager/types/v1" - "github.com/kubescape/storage/pkg/apis/softwarecomposition/v1beta1" ) type RulePolicyValidator struct { @@ -20,17 +19,17 @@ func NewRulePolicyValidator(objectCache objectcache.ObjectCache) *RulePolicyVali } } -func (v *RulePolicyValidator) Validate(ruleId string, process string, cp *v1beta1.ContainerProfile) (bool, error) { - if _, ok := cp.Spec.PolicyByRuleId[ruleId]; !ok { +func (v *RulePolicyValidator) Validate(ruleId string, process string, pcp *objectcache.ProjectedContainerProfile) (bool, error) { + if pcp == nil { return false, nil } - - if policy, ok := cp.Spec.PolicyByRuleId[ruleId]; ok { - if policy.AllowedContainer || slices.Contains(policy.AllowedProcesses, process) { - return true, nil - } + policy, ok := pcp.PolicyByRuleId[ruleId] + if !ok { + return false, nil + } + if policy.AllowedContainer || slices.Contains(policy.AllowedProcesses, process) { + return true, nil } - return false, nil } diff --git a/pkg/rulemanager/types/v1/profiledata.go b/pkg/rulemanager/types/v1/profiledata.go new file mode 100644 index 0000000000..257be7660a --- /dev/null +++ b/pkg/rulemanager/types/v1/profiledata.go @@ -0,0 +1,214 @@ +package types + +import ( + "encoding/json" + "fmt" + + "gopkg.in/yaml.v3" +) + +// ProfileDataRequired declares the per-rule profile fields the rule queries. +// Nil means the rule reads no profile data. +type ProfileDataRequired struct { + Opens FieldRequirement `json:"opens,omitempty" yaml:"opens,omitempty"` + Execs FieldRequirement `json:"execs,omitempty" yaml:"execs,omitempty"` + Capabilities FieldRequirement `json:"capabilities,omitempty" yaml:"capabilities,omitempty"` + Syscalls FieldRequirement `json:"syscalls,omitempty" yaml:"syscalls,omitempty"` + Endpoints FieldRequirement `json:"endpoints,omitempty" yaml:"endpoints,omitempty"` + EgressDomains FieldRequirement `json:"egressDomains,omitempty" yaml:"egressDomains,omitempty"` + EgressAddresses FieldRequirement `json:"egressAddresses,omitempty" yaml:"egressAddresses,omitempty"` + IngressDomains FieldRequirement `json:"ingressDomains,omitempty" yaml:"ingressDomains,omitempty"` + IngressAddresses FieldRequirement `json:"ingressAddresses,omitempty" yaml:"ingressAddresses,omitempty"` +} + +var profileDataRequiredKnownFields = map[string]bool{ + "opens": true, "execs": true, "capabilities": true, + "syscalls": true, "endpoints": true, + "egressDomains": true, "egressAddresses": true, + "ingressDomains": true, "ingressAddresses": true, +} + +// UnmarshalJSON rejects unknown fields. +func (p *ProfileDataRequired) UnmarshalJSON(data []byte) error { + *p = ProfileDataRequired{} // reset to avoid stale state if receiver is reused + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + for k := range raw { + if !profileDataRequiredKnownFields[k] { + return fmt.Errorf("profileDataRequired: unknown field %q", k) + } + } + type plain ProfileDataRequired + return json.Unmarshal(data, (*plain)(p)) +} + +// UnmarshalYAML rejects unknown fields. +func (p *ProfileDataRequired) UnmarshalYAML(value *yaml.Node) error { + *p = ProfileDataRequired{} // reset to avoid stale state if receiver is reused + if value.Kind == yaml.MappingNode { + for i := 0; i < len(value.Content)-1; i += 2 { + key := value.Content[i].Value + if !profileDataRequiredKnownFields[key] { + return fmt.Errorf("profileDataRequired: unknown field %q", key) + } + } + } + type plain ProfileDataRequired + return value.Decode((*plain)(p)) +} + +// FieldRequirement is the per-field declaration. After unmarshalling, exactly +// one of (All, Patterns) is meaningful. Declared=true when the YAML key was +// present, letting the spec compiler distinguish absent-from-this-rule vs +// explicitly declared. +type FieldRequirement struct { + All bool + Patterns []PatternObject + Declared bool +} + +// PatternObject — exactly one of {Exact, Prefix, Suffix, Contains} is non-empty. +// Multi-key or empty objects are rejected at unmarshal time. +type PatternObject struct { + Exact string `json:"exact,omitempty" yaml:"exact,omitempty"` + Prefix string `json:"prefix,omitempty" yaml:"prefix,omitempty"` + Suffix string `json:"suffix,omitempty" yaml:"suffix,omitempty"` + Contains string `json:"contains,omitempty" yaml:"contains,omitempty"` +} + +var patternObjectKnownFields = map[string]bool{ + "exact": true, "prefix": true, "suffix": true, "contains": true, +} + +// UnmarshalJSON rejects unknown fields in a PatternObject so typos in rule +// YAML/JSON are caught at load time rather than silently ignored. +func (p *PatternObject) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + for k := range raw { + if !patternObjectKnownFields[k] { + return fmt.Errorf("PatternObject: unknown field %q", k) + } + } + type plain PatternObject + return json.Unmarshal(data, (*plain)(p)) +} + +// UnmarshalYAML rejects unknown fields in a PatternObject. +func (p *PatternObject) UnmarshalYAML(value *yaml.Node) error { + if value.Kind == yaml.MappingNode { + for i := 0; i < len(value.Content)-1; i += 2 { + key := value.Content[i].Value + if !patternObjectKnownFields[key] { + return fmt.Errorf("PatternObject: unknown field %q", key) + } + } + } + type plain PatternObject + return value.Decode((*plain)(p)) +} + +// validate checks that exactly one field is set. +func (p PatternObject) validate() error { + count := 0 + if p.Exact != "" { + count++ + } + if p.Prefix != "" { + count++ + } + if p.Suffix != "" { + count++ + } + if p.Contains != "" { + count++ + } + if count == 0 { + return fmt.Errorf("PatternObject must have exactly one non-empty field (exact/prefix/suffix/contains), got none") + } + if count > 1 { + return fmt.Errorf("PatternObject must have exactly one non-empty field (exact/prefix/suffix/contains), got %d", count) + } + return nil +} + +// UnmarshalJSON for FieldRequirement: accepts the string "all" or a non-empty +// JSON array of PatternObject. +func (f *FieldRequirement) UnmarshalJSON(data []byte) error { + *f = FieldRequirement{} // reset to clear any stale All/Patterns before decode + f.Declared = true + + // Try string "all" + var s string + if err := json.Unmarshal(data, &s); err == nil { + if s != "all" { + return fmt.Errorf("FieldRequirement string value must be \"all\", got %q", s) + } + f.All = true + return nil + } + + // Try array of PatternObject + var patterns []PatternObject + if err := json.Unmarshal(data, &patterns); err != nil { + return fmt.Errorf("FieldRequirement must be \"all\" or a list of pattern objects: %w", err) + } + if len(patterns) == 0 { + return fmt.Errorf("FieldRequirement pattern list must be non-empty; use \"all\" to retain all entries") + } + for i, p := range patterns { + if err := p.validate(); err != nil { + return fmt.Errorf("FieldRequirement[%d]: %w", i, err) + } + } + f.Patterns = patterns + return nil +} + +// MarshalJSON for FieldRequirement: emits "all" or the pattern list. +func (f FieldRequirement) MarshalJSON() ([]byte, error) { + if !f.Declared { + return []byte("null"), nil + } + if f.All { + return []byte(`"all"`), nil + } + return json.Marshal(f.Patterns) +} + +// UnmarshalYAML for FieldRequirement: accepts the string "all" or a non-empty +// sequence of pattern objects. +func (f *FieldRequirement) UnmarshalYAML(unmarshal func(any) error) error { + *f = FieldRequirement{} // reset to clear any stale All/Patterns before decode + f.Declared = true + + // Try string first. + var s string + if err := unmarshal(&s); err == nil { + if s != "all" { + return fmt.Errorf("FieldRequirement string value must be \"all\", got %q", s) + } + f.All = true + return nil + } + + // Try slice of PatternObject. + var patterns []PatternObject + if err := unmarshal(&patterns); err != nil { + return fmt.Errorf("FieldRequirement must be \"all\" or a list of pattern objects: %w", err) + } + if len(patterns) == 0 { + return fmt.Errorf("FieldRequirement pattern list must be non-empty; use \"all\" to retain all entries") + } + for i, p := range patterns { + if err := p.validate(); err != nil { + return fmt.Errorf("FieldRequirement[%d]: %w", i, err) + } + } + f.Patterns = patterns + return nil +} diff --git a/pkg/rulemanager/types/v1/profiledata_test.go b/pkg/rulemanager/types/v1/profiledata_test.go new file mode 100644 index 0000000000..b8e7b599d4 --- /dev/null +++ b/pkg/rulemanager/types/v1/profiledata_test.go @@ -0,0 +1,264 @@ +package types + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// --- YAML unmarshaling tests --- + +// TestProfileDataRequired_Unmarshal_AllString verifies that the string "all" +// unmarshals to FieldRequirement{Declared:true, All:true}. +func TestProfileDataRequired_Unmarshal_AllString(t *testing.T) { + input := `opens: all` + var pdr ProfileDataRequired + err := yaml.Unmarshal([]byte(input), &pdr) + require.NoError(t, err) + + assert.True(t, pdr.Opens.Declared, "Declared should be true when field is present in YAML") + assert.True(t, pdr.Opens.All, "All should be true when value is 'all'") + assert.Empty(t, pdr.Opens.Patterns, "Patterns should be empty when value is 'all'") +} + +// TestProfileDataRequired_Unmarshal_Patterns verifies that a list of pattern +// objects unmarshals correctly. +func TestProfileDataRequired_Unmarshal_Patterns(t *testing.T) { + input := ` +opens: + - exact: /bin/sh + - prefix: /usr/ +` + var pdr ProfileDataRequired + err := yaml.Unmarshal([]byte(input), &pdr) + require.NoError(t, err) + + assert.True(t, pdr.Opens.Declared) + assert.False(t, pdr.Opens.All) + require.Len(t, pdr.Opens.Patterns, 2, "should have two pattern entries") + + // Find exact and prefix entries (order may vary). + var exactFound, prefixFound bool + for _, p := range pdr.Opens.Patterns { + if p.Exact == "/bin/sh" { + exactFound = true + } + if p.Prefix == "/usr/" { + prefixFound = true + } + } + assert.True(t, exactFound, "exact /bin/sh pattern should be present") + assert.True(t, prefixFound, "prefix /usr/ pattern should be present") +} + +// TestProfileDataRequired_Unmarshal_NilField verifies that an omitted field +// results in Declared=false. +func TestProfileDataRequired_Unmarshal_NilField(t *testing.T) { + // Only opens is specified; syscalls is omitted. + input := `opens: all` + var pdr ProfileDataRequired + err := yaml.Unmarshal([]byte(input), &pdr) + require.NoError(t, err) + + assert.False(t, pdr.Syscalls.Declared, "omitted syscalls field should have Declared=false") + assert.False(t, pdr.Execs.Declared, "omitted execs field should have Declared=false") +} + +// TestProfileDataRequired_Unmarshal_InvalidPattern verifies that a pattern +// object with two fields is rejected at unmarshal time. +func TestProfileDataRequired_Unmarshal_InvalidPattern(t *testing.T) { + input := ` +opens: + - exact: /a + prefix: /b +` + var pdr ProfileDataRequired + err := yaml.Unmarshal([]byte(input), &pdr) + assert.Error(t, err, "a PatternObject with two fields (exact+prefix) should return an error") +} + +// TestProfileDataRequired_Unmarshal_ValidateSingleField verifies that each +// single-field PatternObject variant is accepted. +func TestProfileDataRequired_Unmarshal_ValidateSingleField(t *testing.T) { + cases := []struct { + name string + input string + }{ + {name: "exact", input: "opens:\n - exact: /bin/sh"}, + {name: "prefix", input: "opens:\n - prefix: /usr/"}, + {name: "suffix", input: "opens:\n - suffix: .log"}, + {name: "contains", input: "opens:\n - contains: http"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var pdr ProfileDataRequired + err := yaml.Unmarshal([]byte(tc.input), &pdr) + require.NoError(t, err, "single-field pattern %q should be valid", tc.name) + assert.True(t, pdr.Opens.Declared) + require.Len(t, pdr.Opens.Patterns, 1) + }) + } +} + +// TestProfileDataRequired_Unmarshal_TwoFieldsInOneObject verifies that a pattern +// object with more than one non-empty field is rejected. +func TestProfileDataRequired_Unmarshal_TwoFieldsInOneObject(t *testing.T) { + cases := []struct { + name string + input string + }{ + { + name: "exact+prefix", + input: "opens:\n - exact: /a\n prefix: /b", + }, + { + name: "suffix+contains", + input: "opens:\n - suffix: .log\n contains: http", + }, + { + name: "exact+suffix", + input: "opens:\n - exact: /bin/sh\n suffix: .sh", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var pdr ProfileDataRequired + err := yaml.Unmarshal([]byte(tc.input), &pdr) + assert.Error(t, err, "multi-field PatternObject %q should be rejected", tc.name) + }) + } +} + +// TestProfileDataRequired_Unmarshal_AllFields verifies that all field names in +// ProfileDataRequired can be round-tripped from YAML. +func TestProfileDataRequired_Unmarshal_AllFields(t *testing.T) { + input := ` +opens: all +execs: + - prefix: /usr/ +capabilities: all +syscalls: + - contains: read +endpoints: all +egressDomains: + - exact: example.com +egressAddresses: + - prefix: 10.0. +ingressDomains: all +ingressAddresses: + - suffix: .local +` + var pdr ProfileDataRequired + err := yaml.Unmarshal([]byte(input), &pdr) + require.NoError(t, err) + + assert.True(t, pdr.Opens.All) + assert.True(t, pdr.Execs.Declared) + assert.False(t, pdr.Execs.All) + require.Len(t, pdr.Execs.Patterns, 1) + assert.Equal(t, "/usr/", pdr.Execs.Patterns[0].Prefix) + + assert.True(t, pdr.Capabilities.All) + assert.True(t, pdr.Syscalls.Declared) + require.Len(t, pdr.Syscalls.Patterns, 1) + assert.Equal(t, "read", pdr.Syscalls.Patterns[0].Contains) + + assert.True(t, pdr.Endpoints.All) + assert.True(t, pdr.EgressDomains.Declared) + assert.Equal(t, "example.com", pdr.EgressDomains.Patterns[0].Exact) + assert.Equal(t, "10.0.", pdr.EgressAddresses.Patterns[0].Prefix) + assert.True(t, pdr.IngressDomains.All) + assert.Equal(t, ".local", pdr.IngressAddresses.Patterns[0].Suffix) +} + +// --- JSON unmarshaling tests --- + +// TestFieldRequirement_JSON_AllString verifies JSON "all" string unmarshaling. +func TestFieldRequirement_JSON_AllString(t *testing.T) { + data := `{"opens": "all"}` + var pdr ProfileDataRequired + err := json.Unmarshal([]byte(data), &pdr) + require.NoError(t, err) + + assert.True(t, pdr.Opens.Declared) + assert.True(t, pdr.Opens.All) +} + +// TestFieldRequirement_JSON_Patterns verifies JSON pattern list unmarshaling. +func TestFieldRequirement_JSON_Patterns(t *testing.T) { + data := `{"opens": [{"exact": "/bin/sh"}, {"prefix": "/usr/"}]}` + var pdr ProfileDataRequired + err := json.Unmarshal([]byte(data), &pdr) + require.NoError(t, err) + + assert.True(t, pdr.Opens.Declared) + assert.False(t, pdr.Opens.All) + require.Len(t, pdr.Opens.Patterns, 2) +} + +// TestFieldRequirement_JSON_InvalidString verifies that a non-"all" string +// value is rejected. +func TestFieldRequirement_JSON_InvalidString(t *testing.T) { + data := `{"opens": "some"}` + var pdr ProfileDataRequired + err := json.Unmarshal([]byte(data), &pdr) + assert.Error(t, err, `string value other than "all" should be rejected`) +} + +// TestFieldRequirement_JSON_TwoFieldPattern verifies that a multi-field pattern +// object is rejected during JSON unmarshaling. +func TestFieldRequirement_JSON_TwoFieldPattern(t *testing.T) { + data := `{"opens": [{"exact": "/a", "prefix": "/b"}]}` + var pdr ProfileDataRequired + err := json.Unmarshal([]byte(data), &pdr) + assert.Error(t, err, "multi-field PatternObject should be rejected in JSON") +} + +// TestFieldRequirement_MarshalJSON_All verifies that MarshalJSON for All=true +// emits the string "all". +func TestFieldRequirement_MarshalJSON_All(t *testing.T) { + f := FieldRequirement{Declared: true, All: true} + data, err := json.Marshal(f) + require.NoError(t, err) + assert.Equal(t, `"all"`, string(data)) +} + +// TestFieldRequirement_MarshalJSON_NotDeclared verifies that MarshalJSON for +// Declared=false emits null. +func TestFieldRequirement_MarshalJSON_NotDeclared(t *testing.T) { + f := FieldRequirement{Declared: false} + data, err := json.Marshal(f) + require.NoError(t, err) + assert.Equal(t, `null`, string(data)) +} + +// TestFieldRequirement_MarshalJSON_Patterns verifies that MarshalJSON for +// pattern lists emits the correct JSON array. +func TestFieldRequirement_MarshalJSON_Patterns(t *testing.T) { + f := FieldRequirement{ + Declared: true, + Patterns: []PatternObject{ + {Exact: "/bin/sh"}, + {Prefix: "/usr/"}, + }, + } + data, err := json.Marshal(f) + require.NoError(t, err) + assert.Contains(t, string(data), `"exact":"/bin/sh"`) + assert.Contains(t, string(data), `"prefix":"/usr/"`) +} + +// TestPatternObject_Validate_EmptyObject verifies that a PatternObject with no +// fields is rejected. +func TestPatternObject_Validate_EmptyObject(t *testing.T) { + // Use JSON unmarshaling path to trigger validate. + data := `{"opens": [{}]}` + var pdr ProfileDataRequired + err := json.Unmarshal([]byte(data), &pdr) + assert.Error(t, err, "empty PatternObject should be rejected") +} diff --git a/pkg/rulemanager/types/v1/types.go b/pkg/rulemanager/types/v1/types.go index 4658974baf..20e387552c 100644 --- a/pkg/rulemanager/types/v1/types.go +++ b/pkg/rulemanager/types/v1/types.go @@ -25,6 +25,7 @@ type Rule struct { Description string `json:"description" yaml:"description"` Expressions RuleExpressions `json:"expressions" yaml:"expressions"` ProfileDependency armotypes.ProfileDependency `json:"profileDependency" yaml:"profileDependency"` + ProfileDataRequired *ProfileDataRequired `json:"profileDataRequired,omitempty" yaml:"profileDataRequired,omitempty"` Severity int `json:"severity" yaml:"severity"` SupportPolicy bool `json:"supportPolicy" yaml:"supportPolicy"` Tags []string `json:"tags" yaml:"tags"` @@ -33,7 +34,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:"-"` + Prefilter *prefilter.Params `json:"-" yaml:"-"` } type RuleExpressions struct { diff --git a/tests/chart/crds/rules.crd.yaml b/tests/chart/crds/rules.crd.yaml index e4e1155eaf..90d5d56712 100644 --- a/tests/chart/crds/rules.crd.yaml +++ b/tests/chart/crds/rules.crd.yaml @@ -75,6 +75,10 @@ spec: type: integer enum: [0, 1, 2] description: "Profile dependency level (0=Required, 1=Optional, 2=NotRequired)" + profileDataRequired: + type: object + x-kubernetes-preserve-unknown-fields: true + description: "Per-rule profile fields required for rule-aware projection." severity: type: integer description: "Severity level of the rule" @@ -91,6 +95,19 @@ spec: type: object additionalProperties: true description: "State information for the rule" + agentVersionRequirement: + type: string + description: "Agent version requirement to evaluate this rule (supports semver ranges like ~1.0, >=1.2.0, etc.)" + isTriggerAlert: + type: boolean + description: "Whether the rule is a trigger alert" + default: true + mitreTechnique: + type: string + description: "MITRE technique associated with the rule" + mitreTactic: + type: string + description: "MITRE tactic associated with the rule" required: - enabled - id @@ -100,7 +117,9 @@ spec: - profileDependency - severity - supportPolicy - - tags + - isTriggerAlert + - mitreTechnique + - mitreTactic required: - rules subresources: diff --git a/tests/chart/templates/node-agent/configmap.yaml b/tests/chart/templates/node-agent/configmap.yaml index 11cccc3eee..523b5bbac6 100644 --- a/tests/chart/templates/node-agent/configmap.yaml +++ b/tests/chart/templates/node-agent/configmap.yaml @@ -36,7 +36,8 @@ data: "celConfigCache": { "maxSize": {{ .Values.nodeAgent.config.celConfigCache.maxSize }}, "ttl": "{{ .Values.nodeAgent.config.celConfigCache.ttl }}" - } + }, + "profileProjection": {{- .Values.nodeAgent.config.profileProjection | toJson }} } --- {{- if eq .Values.capabilities.malwareDetection "enable" }} diff --git a/tests/chart/templates/node-agent/default-rules.yaml b/tests/chart/templates/node-agent/default-rules.yaml index 55fd1b527e..0a4fe1d87f 100644 --- a/tests/chart/templates/node-agent/default-rules.yaml +++ b/tests/chart/templates/node-agent/default-rules.yaml @@ -20,12 +20,15 @@ spec: - eventType: "exec" expression: "!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm))" profileDependency: 0 + profileDataRequired: + execs: all severity: 1 supportPolicy: false isTriggerAlert: true mitreTactic: "TA0002" mitreTechnique: "T1059" tags: + - "context:kubernetes" - "anomaly" - "process" - "exec" @@ -59,12 +62,27 @@ spec: && !ap.was_path_opened(event.containerId, event.path) profileDependency: 0 + profileDataRequired: + opens: + - prefix: "/etc/" + - prefix: "/var/log/" + - prefix: "/var/run/" + - prefix: "/run/" + - prefix: "/var/spool/cron/" + - prefix: "/var/www/" + - prefix: "/var/lib/" + - prefix: "/opt/" + - prefix: "/usr/local/" + - prefix: "/app/" + - exact: "/.dockerenv" + - exact: "/proc/self/environ" severity: 1 supportPolicy: false isTriggerAlert: false mitreTactic: "TA0009" mitreTechnique: "T1005" tags: + - "context:kubernetes" - "anomaly" - "file" - "open" @@ -80,12 +98,15 @@ spec: - eventType: "syscall" expression: "!ap.was_syscall_used(event.containerId, event.syscallName)" profileDependency: 0 + profileDataRequired: + syscalls: all severity: 1 supportPolicy: false isTriggerAlert: false mitreTactic: "TA0002" mitreTechnique: "T1059" tags: + - "context:kubernetes" - "anomaly" - "syscall" - "applicationprofile" @@ -100,12 +121,15 @@ spec: - eventType: "capabilities" expression: "!ap.was_capability_used(event.containerId, event.capName)" profileDependency: 0 + profileDataRequired: + capabilities: all severity: 1 supportPolicy: false isTriggerAlert: false mitreTactic: "TA0002" mitreTechnique: "T1059" tags: + - "context:kubernetes" - "anomaly" - "capabilities" - "applicationprofile" @@ -120,12 +144,15 @@ spec: - eventType: "dns" expression: "!event.name.endsWith('.svc.cluster.local.') && !nn.is_domain_in_egress(event.containerId, event.name)" profileDependency: 0 + profileDataRequired: + egressDomains: all severity: 1 supportPolicy: false isTriggerAlert: false mitreTactic: "TA0011" mitreTechnique: "T1071.004" tags: + - "context:kubernetes" - "dns" - "anomaly" - "networkprofile" @@ -139,21 +166,26 @@ spec: ruleExpression: - eventType: "open" expression: > - ((event.path.startsWith('/run/secrets/kubernetes.io/serviceaccount') && event.path.endsWith('/token')) || + ((event.path.startsWith('/run/secrets/kubernetes.io/serviceaccount') && event.path.endsWith('/token')) || (event.path.startsWith('/var/run/secrets/kubernetes.io/serviceaccount') && event.path.endsWith('/token')) || (event.path.startsWith('/run/secrets/eks.amazonaws.com/serviceaccount') && event.path.endsWith('/token')) || (event.path.startsWith('/var/run/secrets/eks.amazonaws.com/serviceaccount') && event.path.endsWith('/token'))) && - !ap.was_path_opened_with_prefix(event.containerId, '/run/secrets/kubernetes.io/serviceaccount') && - !ap.was_path_opened_with_prefix(event.containerId, '/var/run/secrets/kubernetes.io/serviceaccount') && - !ap.was_path_opened_with_prefix(event.containerId, '/run/secrets/eks.amazonaws.com/serviceaccount') && - !ap.was_path_opened_with_prefix(event.containerId, '/var/run/secrets/eks.amazonaws.com/serviceaccount') - profileDependency: 1 + !ap.was_path_opened_with_suffix(event.containerId, '/token') + state: + includePrefixes: + - /run/secrets + - /var/run/secrets + profileDependency: 0 + profileDataRequired: + opens: + - suffix: "/token" severity: 5 supportPolicy: false isTriggerAlert: true mitreTactic: "TA0006" mitreTechnique: "T1528" tags: + - "context:kubernetes" - "anomaly" - "serviceaccount" - "applicationprofile" @@ -170,12 +202,16 @@ spec: - eventType: "network" expression: "event.pktType == 'OUTGOING' && k8s.is_api_server_address(event.dstAddr) && !nn.was_address_in_egress(event.containerId, event.dstAddr)" profileDependency: 0 + profileDataRequired: + execs: all + egressAddresses: all severity: 5 # Medium supportPolicy: false - isTriggerAlert: true + isTriggerAlert: false mitreTactic: "TA0008" mitreTechnique: "T1210" tags: + - "context:kubernetes" - "exec" - "network" - "anomaly" @@ -186,20 +222,27 @@ spec: description: "Detecting reading environment variables from procfs." expressions: message: "'Reading environment variables from procfs: ' + event.path + ' by process ' + event.comm" - uniqueId: "event.comm + '_' + event.path" + uniqueId: "event.comm" ruleExpression: - eventType: "open" expression: > - event.path.startsWith('/proc/') && + event.path.startsWith('/proc/') && event.path.endsWith('/environ') && !ap.was_path_opened_with_suffix(event.containerId, '/environ') + state: + includePrefixes: + - /proc profileDependency: 0 # Required + profileDataRequired: + opens: + - suffix: "/environ" severity: 5 # Medium supportPolicy: false isTriggerAlert: true mitreTactic: "TA0006" mitreTechnique: "T1552.001" tags: + - "context:kubernetes" - "anomaly" - "procfs" - "environment" @@ -215,12 +258,17 @@ spec: - eventType: "bpf" expression: "event.cmd == uint(5) && !ap.was_syscall_used(event.containerId, 'bpf')" profileDependency: 1 + profileDataRequired: + syscalls: + - exact: "bpf" severity: 5 supportPolicy: false isTriggerAlert: true mitreTactic: "TA0005" mitreTechnique: "T1218" tags: + - "context:kubernetes" + - "context:host" - "bpf" - "ebpf" - "applicationprofile" @@ -235,12 +283,17 @@ spec: - eventType: "open" expression: "event.path.startsWith('/etc/shadow') && !ap.was_path_opened(event.containerId, event.path)" profileDependency: 1 + profileDataRequired: + opens: + - prefix: "/etc/shadow" severity: 5 supportPolicy: false isTriggerAlert: true mitreTactic: "TA0006" mitreTechnique: "T1005" tags: + - "context:kubernetes" + - "context:host" - "files" - "anomaly" - "applicationprofile" @@ -255,12 +308,15 @@ spec: - eventType: "network" expression: "event.pktType == 'OUTGOING' && !net.is_private_ip(event.dstAddr) && !nn.was_address_in_egress(event.containerId, event.dstAddr)" profileDependency: 0 + profileDataRequired: + egressAddresses: all severity: 5 # Medium supportPolicy: false isTriggerAlert: false mitreTactic: "TA0010" mitreTechnique: "T1041" tags: + - "context:kubernetes" - "whitelisted" - "network" - "anomaly" @@ -276,7 +332,7 @@ spec: - eventType: "exec" expression: > (event.exepath == '/dev/shm' || event.exepath.startsWith('/dev/shm/')) || - (event.cwd == '/dev/shm' || event.cwd.startsWith('/dev/shm/') || + (event.cwd == '/dev/shm' || event.cwd.startsWith('/dev/shm/') || (parse.get_exec_path(event.args, event.comm).startsWith('/dev/shm/'))) profileDependency: 2 severity: 8 @@ -285,6 +341,8 @@ spec: mitreTactic: "TA0002" mitreTechnique: "T1059" tags: + - "context:kubernetes" + - "context:host" - "exec" - "signature" - "malicious" @@ -302,12 +360,15 @@ spec: event.pupperlayer == true) && !ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm)) profileDependency: 1 + profileDataRequired: + execs: all severity: 8 supportPolicy: false isTriggerAlert: true mitreTactic: "TA0005" mitreTechnique: "T1036" tags: + - "context:kubernetes" - "exec" - "malicious" - "binary" @@ -330,6 +391,8 @@ spec: mitreTactic: "TA0005" mitreTechnique: "T1547.006" tags: + - "context:kubernetes" + - "context:host" - "kmod" - "kernel" - "module" @@ -345,12 +408,15 @@ spec: - eventType: "ssh" expression: "dyn(event.srcPort) >= 32768 && dyn(event.srcPort) <= 60999 && !(dyn(event.dstPort) in [22, 2022]) && !nn.was_address_in_egress(event.containerId, event.dstIp)" profileDependency: 1 + profileDataRequired: + egressAddresses: all severity: 5 supportPolicy: false isTriggerAlert: true mitreTactic: "TA0008" mitreTechnique: "T1021.001" tags: + - "context:kubernetes" - "ssh" - "connection" - "port" @@ -362,17 +428,20 @@ spec: description: "Detecting exec calls from mounted paths." expressions: message: "'Process (' + event.comm + ') was executed from a mounted path'" - uniqueId: "event.comm + '_' + event.exepath + '_'" + uniqueId: "event.comm" ruleExpression: - eventType: "exec" expression: "!ap.was_executed(event.containerId, parse.get_exec_path(event.args, event.comm)) && k8s.get_container_mount_paths(event.namespace, event.podName, event.containerName).exists(mount, event.exepath.startsWith(mount) || parse.get_exec_path(event.args, event.comm).startsWith(mount))" profileDependency: 1 + profileDataRequired: + execs: all severity: 5 supportPolicy: false isTriggerAlert: true mitreTactic: "TA0002" mitreTechnique: "T1059" tags: + - "context:kubernetes" - "exec" - "mount" - "applicationprofile" @@ -393,6 +462,8 @@ spec: mitreTactic: "TA0005" mitreTechnique: "T1055" tags: + - "context:kubernetes" + - "context:host" - "fileless" - "execution" - "malicious" @@ -405,14 +476,18 @@ spec: uniqueId: "event.comm + '_' + 'unshare'" ruleExpression: - eventType: "unshare" - expression: "!ap.was_syscall_used(event.containerId, 'unshare')" - profileDependency: 2 + expression: "event.pcomm != 'runc' && !ap.was_syscall_used(event.containerId, 'unshare')" + profileDependency: 1 + profileDataRequired: + syscalls: + - exact: "unshare" severity: 5 supportPolicy: false isTriggerAlert: true mitreTactic: "TA0004" mitreTechnique: "T1611" tags: + - "context:kubernetes" - "unshare" - "escape" - "unshare" @@ -435,6 +510,7 @@ spec: mitreTactic: "TA0040" mitreTechnique: "T1496" tags: + - "context:kubernetes" - "crypto" - "miners" - "malicious" @@ -447,7 +523,7 @@ spec: uniqueId: "event.name + '_' + event.comm" ruleExpression: - eventType: "dns" - expression: "event.name in ['2cryptocalc.com.', '2miners.com.', 'antpool.com.', 'asia1.ethpool.org.', 'bohemianpool.com.', 'botbox.dev.', 'btm.antpool.com.', 'c3pool.com.', 'c4pool.org.', 'ca.minexmr.com.', 'cn.stratum.slushpool.com.', 'dash.antpool.com.', 'data.miningpoolstats.stream.', 'de.minexmr.com.', 'eth-ar.dwarfpool.com.', 'eth-asia.dwarfpool.com.', 'eth-asia1.nanopool.org.', 'eth-au.dwarfpool.com.', 'eth-au1.nanopool.org.', 'eth-br.dwarfpool.com.', 'eth-cn.dwarfpool.com.', 'eth-cn2.dwarfpool.com.', 'eth-eu.dwarfpool.com.', 'eth-eu1.nanopool.org.', 'eth-eu2.nanopool.org.', 'eth-hk.dwarfpool.com.', 'eth-jp1.nanopool.org.', 'eth-ru.dwarfpool.com.', 'eth-ru2.dwarfpool.com.', 'eth-sg.dwarfpool.com.', 'eth-us-east1.nanopool.org.', 'eth-us-west1.nanopool.org.', 'eth-us.dwarfpool.com.', 'eth-us2.dwarfpool.com.', 'eth.antpool.com.', 'eu.stratum.slushpool.com.', 'eu1.ethermine.org.', 'eu1.ethpool.org.', 'fastpool.xyz.', 'fr.minexmr.com.', 'kriptokyng.com.', 'mine.moneropool.com.', 'mine.xmrpool.net.', 'miningmadness.com.', 'monero.cedric-crispin.com.', 'monero.crypto-pool.fr.', 'monero.fairhash.org.', 'monero.hashvault.pro.', 'monero.herominers.com.', 'monerod.org.', 'monerohash.com.', 'moneroocean.stream.', 'monerop.com.', 'multi-pools.com.', 'p2pool.io.', 'pool.kryptex.com.', 'pool.minexmr.com.', 'pool.monero.hashvault.pro.', 'pool.rplant.xyz.', 'pool.supportxmr.com.', 'pool.xmr.pt.', 'prohashing.com.', 'rx.unmineable.com.', 'sg.minexmr.com.', 'sg.stratum.slushpool.com.', 'skypool.org.', 'solo-xmr.2miners.com.', 'ss.antpool.com.', 'stratum-btm.antpool.com.', 'stratum-dash.antpool.com.', 'stratum-eth.antpool.com.', 'stratum-ltc.antpool.com.', 'stratum-xmc.antpool.com.', 'stratum-zec.antpool.com.', 'stratum.antpool.com.', 'supportxmr.com.', 'trustpool.cc.', 'us-east.stratum.slushpool.com.', 'us1.ethermine.org.', 'us1.ethpool.org.', 'us2.ethermine.org.', 'us2.ethpool.org.', 'web.xmrpool.eu.', 'www.domajorpool.com.', 'www.dxpool.com.', 'www.mining-dutch.nl.', 'xmc.antpool.com.', 'xmr-asia1.nanopool.org.', 'xmr-au1.nanopool.org.', 'xmr-eu1.nanopool.org.', 'xmr-eu2.nanopool.org.', 'xmr-jp1.nanopool.org.', 'xmr-us-east1.nanopool.org.', 'xmr-us-west1.nanopool.org.', 'xmr.2miners.com.', 'xmr.crypto-pool.fr.', 'xmr.gntl.uk.', 'xmr.nanopool.org.', 'xmr.pool-pay.com.', 'xmr.pool.minergate.com.', 'xmr.solopool.org.', 'xmr.volt-mine.com.', 'xmr.zeropool.io.', 'zec.antpool.com.', 'zergpool.com.', 'auto.c3pool.org.', 'us.monero.herominers.com.']" + expression: "event.name in ['2cryptocalc.com.', '2miners.com.', 'antpool.com.', 'asia1.ethpool.org.', 'bohemianpool.com.', 'botbox.dev.', 'btm.antpool.com.', 'c3pool.com.', 'c4pool.org.', 'ca.minexmr.com.', 'cn.stratum.slushpool.com.', 'dash.antpool.com.', 'data.miningpoolstats.stream.', 'de.minexmr.com.', 'eth-ar.dwarfpool.com.', 'eth-asia.dwarfpool.com.', 'eth-asia1.nanopool.org.', 'eth-au.dwarfpool.com.', 'eth-au1.nanopool.org.', 'eth-br.dwarfpool.com.', 'eth-cn.dwarfpool.com.', 'eth-cn2.dwarfpool.com.', 'eth-eu.dwarfpool.com.', 'eth-eu1.nanopool.org.', 'eth-eu2.nanopool.org.', 'eth-hk.dwarfpool.com.', 'eth-jp1.nanopool.org.', 'eth-ru.dwarfpool.com.', 'eth-ru2.dwarfpool.com.', 'eth-sg.dwarfpool.com.', 'eth-us-east1.nanopool.org.', 'eth-us-west1.nanopool.org.', 'eth-us.dwarfpool.com.', 'eth-us2.dwarfpool.com.', 'eth.antpool.com.', 'eu.stratum.slushpool.com.', 'eu1.ethermine.org.', 'eu1.ethpool.org.', 'fastpool.xyz.', 'fr.minexmr.com.', 'kriptokyng.com.', 'mine.moneropool.com.', 'mine.xmrpool.net.', 'miningmadness.com.', 'monero.cedric-crispin.com.', 'monero.crypto-pool.fr.', 'monero.fairhash.org.', 'monero.hashvault.pro.', 'monero.herominers.com.', 'monerod.org.', 'monerohash.com.', 'moneroocean.stream.', 'monerop.com.', 'multi-pools.com.', 'p2pool.io.', 'pool.kryptex.com.', 'pool.minexmr.com.', 'pool.monero.hashvault.pro.', 'pool.rplant.xyz.', 'pool.supportxmr.com.', 'pool.xmr.pt.', 'prohashing.com.', 'rx.unmineable.com.', 'sg.minexmr.com.', 'sg.stratum.slushpool.com.', 'skypool.org.', 'solo-xmr.2miners.com.', 'ss.antpool.com.', 'stratum-btm.antpool.com.', 'stratum-dash.antpool.com.', 'stratum-eth.antpool.com.', 'stratum-ltc.antpool.com.', 'stratum-xmc.antpool.com.', 'stratum-zec.antpool.com.', 'stratum.antpool.com.', 'supportxmr.com.', 'trustpool.cc.', 'us-east.stratum.slushpool.com.', 'us1.ethermine.org.', 'us1.ethpool.org.', 'us2.ethermine.org.', 'us2.ethpool.org.', 'web.xmrpool.eu.', 'www.domajorpool.com.', 'www.dxpool.com.', 'www.mining-dutch.nl.', 'xmc.antpool.com.', 'xmr-asia1.nanopool.org.', 'xmr-au1.nanopool.org.', 'xmr-eu1.nanopool.org.', 'xmr-eu2.nanopool.org.', 'xmr-jp1.nanopool.org.', 'xmr-us-east1.nanopool.org.', 'xmr-us-west1.nanopool.org.', 'xmr.2miners.com.', 'xmr.crypto-pool.fr.', 'xmr.gntl.uk.', 'xmr.nanopool.org.', 'xmr.pool-pay.com.', 'xmr.pool.minergate.com.', 'xmr.solopool.org.', 'xmr.volt-mine.com.', 'xmr.zeropool.io.', 'zec.antpool.com.', 'zergpool.com.', 'auto.c3pool.org.', 'us.monero.herominers.com.', 'xmr.kryptex.network.']" profileDependency: 2 severity: 10 supportPolicy: false @@ -455,6 +531,8 @@ spec: mitreTactic: "TA0011" mitreTechnique: "T1071.004" tags: + - "context:kubernetes" + - "context:host" - "network" - "crypto" - "miners" @@ -470,13 +548,21 @@ spec: ruleExpression: - eventType: "network" expression: "event.proto == 'TCP' && event.pktType == 'OUTGOING' && event.dstPort in [3333, 45700] && !nn.was_address_in_egress(event.containerId, event.dstAddr)" + state: + ports: + - 3333 + - 45700 profileDependency: 1 + profileDataRequired: + egressAddresses: all severity: 3 supportPolicy: false isTriggerAlert: false mitreTactic: "TA0011" mitreTechnique: "T1071" tags: + - "context:kubernetes" + - "context:host" - "network" - "crypto" - "miners" @@ -493,12 +579,18 @@ spec: - eventType: "symlink" expression: "(event.oldPath.startsWith('/etc/shadow') || event.oldPath.startsWith('/etc/sudoers')) && !ap.was_path_opened(event.containerId, event.oldPath)" profileDependency: 1 + profileDataRequired: + opens: + - prefix: "/etc/shadow" + - prefix: "/etc/sudoers" severity: 5 supportPolicy: true isTriggerAlert: true mitreTactic: "TA0006" mitreTechnique: "T1005" tags: + - "context:kubernetes" + - "context:host" - "anomaly" - "symlink" - "applicationprofile" @@ -515,12 +607,16 @@ spec: - eventType: "open" expression: "event.path == '/etc/ld.so.preload' && has(event.flagsRaw) && event.flagsRaw != 0" profileDependency: 1 + profileDataRequired: + opens: + - exact: "/etc/ld.so.preload" severity: 5 supportPolicy: true isTriggerAlert: true mitreTactic: "TA0005" mitreTechnique: "T1574.006" tags: + - "context:kubernetes" - "exec" - "malicious" - "applicationprofile" @@ -535,12 +631,17 @@ spec: - eventType: "hardlink" expression: "(event.oldPath.startsWith('/etc/shadow') || event.oldPath.startsWith('/etc/sudoers')) && !ap.was_path_opened(event.containerId, event.oldPath)" profileDependency: 1 + profileDataRequired: + opens: + - prefix: "/etc/shadow" + - prefix: "/etc/sudoers" severity: 5 supportPolicy: true isTriggerAlert: true mitreTactic: "TA0006" mitreTechnique: "T1005" tags: + - "context:kubernetes" - "files" - "malicious" - "applicationprofile" @@ -561,6 +662,8 @@ spec: mitreTactic: "TA0005" mitreTechnique: "T1622" tags: + - "context:kubernetes" + - "context:host" - "process" - "malicious" - name: "Unexpected io_uring Operation Detected" @@ -574,12 +677,15 @@ spec: - eventType: "iouring" expression: "true" profileDependency: 0 + profileDataRequired: + syscalls: all severity: 5 supportPolicy: true isTriggerAlert: true mitreTactic: "TA0002" mitreTechnique: "T1218" tags: + - "context:kubernetes" - "syscalls" - "io_uring" - "applicationprofile" diff --git a/tests/chart/values.yaml b/tests/chart/values.yaml index 7cf029c4c8..1aea3a150f 100644 --- a/tests/chart/values.yaml +++ b/tests/chart/values.yaml @@ -74,6 +74,9 @@ nodeAgent: celConfigCache: maxSize: 250000 ttl: 1s + profileProjection: + detailedMetricsEnabled: true + strictValidation: false serviceMonitor: enabled: true diff --git a/tests/component_test.go b/tests/component_test.go index 8a83226321..fcdb760bfb 100644 --- a/tests/component_test.go +++ b/tests/component_test.go @@ -1206,7 +1206,7 @@ func Test_17_ApCompletedToPartialUpdateTest(t *testing.T) { time.Sleep(30 * time.Second) - _, _, err = wl.ExecIntoPod([]string{"ls", "-l"}, "") + _, _, err = wl.ExecIntoPod([]string{"sh", "-c", "cat /run/secrets/kubernetes.io/serviceaccount/token >/dev/null"}, "") require.NoError(t, err) time.Sleep(30 * time.Second) @@ -1214,7 +1214,7 @@ func Test_17_ApCompletedToPartialUpdateTest(t *testing.T) { alerts, err := testutils.GetAlerts(wl.Namespace) require.NoError(t, err, "Error getting alerts") - testutils.AssertContains(t, alerts, "Unexpected process launched", "ls", "nginx", []bool{true}) + testutils.AssertContains(t, alerts, "Unexpected service account token access", "cat", "nginx", []bool{true}) } func Test_18_ShortLivedJobTest(t *testing.T) { diff --git a/tests/resources/malicious-job.yaml b/tests/resources/malicious-job.yaml index 6a6ee85b24..6473860d4e 100644 --- a/tests/resources/malicious-job.yaml +++ b/tests/resources/malicious-job.yaml @@ -16,11 +16,18 @@ spec: # sourcecode: node-agent/tests/images/malicious-app image: quay.io/kubescape/node-agent:maliciousapp6 imagePullPolicy: Always + workingDir: /tmp + command: ["/bin/sh", "-c"] + args: + - | + sleep 190 + mkdir -p /var/lib/r0002-test + echo r0002 >/var/lib/r0002-test/marker + cat /var/lib/r0002-test/marker >/dev/null 2>&1 || true + exec /malicious env: - name: WAIT_FOR_SIGTERM value: "true" - - name: WAIT_BEFORE_START - value: "3m" securityContext: capabilities: add: ["IPC_OWNER"] @@ -31,4 +38,4 @@ spec: volumes: - name: mount-for-alert emptyDir: {} - backoffLimit: 1 \ No newline at end of file + backoffLimit: 1